diff --git a/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj b/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj index 7b2b6828b8c..da3beefa46f 100644 --- a/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj +++ b/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj @@ -4438,6 +4438,18 @@ FEE12B412E520E4D00AD9807 /* PromoHistoryRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE12B412E520E4D00AD9808 /* PromoHistoryRecordTests.swift */; }; FEE12B412E520E4D00AD9809 /* PromoHistoryRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE12B412E520E4D00AD9808 /* PromoHistoryRecordTests.swift */; }; FFF6627FC03B410786EF2635 /* RebrandedContextualOnboardingDialogs+SearchCompleted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E776DE170ED47F4B3E992DF /* RebrandedContextualOnboardingDialogs+SearchCompleted.swift */; }; + E5A6E743849E63F8101AC616 /* QuickFeedbackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A043BAE1603803A839C0CB52 /* QuickFeedbackService.swift */; }; + F8FD0BA84A167FE482ED4A92 /* QuickFeedbackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A043BAE1603803A839C0CB52 /* QuickFeedbackService.swift */; }; + C8C8050EED40EF171CC2784D /* QuickFeedbackDiagnosticsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F511D9CB8F68D9D9B0341314 /* QuickFeedbackDiagnosticsCollector.swift */; }; + 638B34FA781359331A3ED13A /* QuickFeedbackDiagnosticsCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F511D9CB8F68D9D9B0341314 /* QuickFeedbackDiagnosticsCollector.swift */; }; + D16140583C0346C5BE2D9B96 /* QuickFeedbackWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6323C46A8F7693E56A2AD5F8 /* QuickFeedbackWindowController.swift */; }; + 1222238CCD1D2149036D6651 /* QuickFeedbackWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6323C46A8F7693E56A2AD5F8 /* QuickFeedbackWindowController.swift */; }; + EEB674AA4DB3CA34C17F48ED /* QuickFeedbackTipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76403F87E9F53E2C9FFA007 /* QuickFeedbackTipController.swift */; }; + C8E4E6A94A9FA76E5ABF3A2A /* QuickFeedbackTipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76403F87E9F53E2C9FFA007 /* QuickFeedbackTipController.swift */; }; + 6F2449E6DE7EE721370575B9 /* QuickFeedbackDiagnosticsCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD3D561804AB2C73BB789AC /* QuickFeedbackDiagnosticsCollectorTests.swift */; }; + 8A96290053C27B6C1245838B /* QuickFeedbackDiagnosticsCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD3D561804AB2C73BB789AC /* QuickFeedbackDiagnosticsCollectorTests.swift */; }; + AD1ABD21CA2DB9C9505A3217 /* QuickFeedbackTipControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30149F658F94CFC627227DB /* QuickFeedbackTipControllerTests.swift */; }; + FB06A1C37CD1DEDC550642FE /* QuickFeedbackTipControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C30149F658F94CFC627227DB /* QuickFeedbackTipControllerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -6820,6 +6832,12 @@ FD23FD2A28816606007F6985 /* AutoconsentMessageProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AutoconsentMessageProtocolTests.swift; path = UnitTests/Autoconsent/AutoconsentMessageProtocolTests.swift; sourceTree = SOURCE_ROOT; }; FD23FD2C2886A81D007F6985 /* AutoconsentManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoconsentManagement.swift; sourceTree = ""; }; FEE12B412E520E4D00AD9808 /* PromoHistoryRecordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromoHistoryRecordTests.swift; sourceTree = ""; }; + A043BAE1603803A839C0CB52 /* QuickFeedbackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackService.swift; sourceTree = ""; }; + F511D9CB8F68D9D9B0341314 /* QuickFeedbackDiagnosticsCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackDiagnosticsCollector.swift; sourceTree = ""; }; + 6323C46A8F7693E56A2AD5F8 /* QuickFeedbackWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackWindowController.swift; sourceTree = ""; }; + C76403F87E9F53E2C9FFA007 /* QuickFeedbackTipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackTipController.swift; sourceTree = ""; }; + ABD3D561804AB2C73BB789AC /* QuickFeedbackDiagnosticsCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackDiagnosticsCollectorTests.swift; sourceTree = ""; }; + C30149F658F94CFC627227DB /* QuickFeedbackTipControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickFeedbackTipControllerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -10403,6 +10421,7 @@ AA3863C227A1E1C000749AB5 /* Feedback */ = { isa = PBXGroup; children = ( + A5534EA92B26FA8ADC30543F /* QuickFeedback */, BB53CF3C2E26CED5007F0B42 /* New */, 372042E02DF9C04400CE099A /* Resources */, AA3D531827A2F24C00074EC1 /* View */, @@ -12149,6 +12168,7 @@ BB0036602E426E9D0016DDEF /* Feedback */ = { isa = PBXGroup; children = ( + C416159555D0D98B0D468E38 /* QuickFeedback */, BB00365C2E426E9D0016DDEF /* Model */, BB00365F2E426E9D0016DDEF /* New */, ); @@ -12926,6 +12946,26 @@ path = Subscription; sourceTree = ""; }; + A5534EA92B26FA8ADC30543F /* QuickFeedback */ = { + isa = PBXGroup; + children = ( + A043BAE1603803A839C0CB52 /* QuickFeedbackService.swift */, + F511D9CB8F68D9D9B0341314 /* QuickFeedbackDiagnosticsCollector.swift */, + 6323C46A8F7693E56A2AD5F8 /* QuickFeedbackWindowController.swift */, + C76403F87E9F53E2C9FFA007 /* QuickFeedbackTipController.swift */, + ); + path = QuickFeedback; + sourceTree = ""; + }; + C416159555D0D98B0D468E38 /* QuickFeedback */ = { + isa = PBXGroup; + children = ( + ABD3D561804AB2C73BB789AC /* QuickFeedbackDiagnosticsCollectorTests.swift */, + C30149F658F94CFC627227DB /* QuickFeedbackTipControllerTests.swift */, + ); + path = QuickFeedback; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -15857,6 +15897,10 @@ 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, 842FE5472F16691D002F8ABC /* WarnBeforeQuitOverlayPresenter.swift in Sources */, DFE9DC288642264669587656 /* WebViewUserAgentProvider.swift in Sources */, + E5A6E743849E63F8101AC616 /* QuickFeedbackService.swift in Sources */, + C8C8050EED40EF171CC2784D /* QuickFeedbackDiagnosticsCollector.swift in Sources */, + D16140583C0346C5BE2D9B96 /* QuickFeedbackWindowController.swift in Sources */, + EEB674AA4DB3CA34C17F48ED /* QuickFeedbackTipController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -16324,6 +16368,8 @@ 5681ED442BDBA5F900F59729 /* SyncBookmarksAdapterTests.swift in Sources */, BB0036652E426EDD0016DDEF /* ReportProblemFormViewModelTests.swift in Sources */, BB0036662E426EDD0016DDEF /* FeedbackTests.swift in Sources */, + 6F2449E6DE7EE721370575B9 /* QuickFeedbackDiagnosticsCollectorTests.swift in Sources */, + AD1ABD21CA2DB9C9505A3217 /* QuickFeedbackTipControllerTests.swift in Sources */, BB0036672E426EDD0016DDEF /* RequestNewFeatureViewModelTests.swift in Sources */, C1F142DB2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift in Sources */, 1C4C0269DA0A67A1081B7FCA /* FreemiumDBPPromoDelegateTests.swift in Sources */, @@ -18085,6 +18131,10 @@ B68D21C32ACBC916002DA3C2 /* ContentBlockingMock.swift in Sources */, AA5C1DD1285A154E0089850C /* RecentlyClosedMenu.swift in Sources */, D1EC9FDAACABFE5B574FD557 /* WebViewUserAgentProvider.swift in Sources */, + F8FD0BA84A167FE482ED4A92 /* QuickFeedbackService.swift in Sources */, + 638B34FA781359331A3ED13A /* QuickFeedbackDiagnosticsCollector.swift in Sources */, + 1222238CCD1D2149036D6651 /* QuickFeedbackWindowController.swift in Sources */, + C8E4E6A94A9FA76E5ABF3A2A /* QuickFeedbackTipController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -18182,6 +18232,8 @@ BB0036622E426E9D0016DDEF /* RequestNewFeatureViewModelTests.swift in Sources */, CC6E897D2E5F08EA00F6BED0 /* SessionRestorePromptCoordinatorTests.swift in Sources */, BB0036642E426E9D0016DDEF /* FeedbackTests.swift in Sources */, + 8A96290053C27B6C1245838B /* QuickFeedbackDiagnosticsCollectorTests.swift in Sources */, + FB06A1C37CD1DEDC550642FE /* QuickFeedbackTipControllerTests.swift in Sources */, 8434D95A2F23747700B4796F /* TerminationDeciderHandlerTests.swift in Sources */, B69B50462726C5C200758A2B /* AtbAndVariantCleanupTests.swift in Sources */, 567DA94529E95C3F008AC5EE /* YoutubeOverlayUserScriptTests.swift in Sources */, diff --git a/macOS/DuckDuckGo/Application/AppDelegate.swift b/macOS/DuckDuckGo/Application/AppDelegate.swift index 31824803685..cf350b7433a 100644 --- a/macOS/DuckDuckGo/Application/AppDelegate.swift +++ b/macOS/DuckDuckGo/Application/AppDelegate.swift @@ -129,6 +129,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private(set) var promoService: PromoService? var privacyDashboardWindow: NSWindow? + @MainActor private(set) lazy var quickFeedbackService: QuickFeedbackService = { + let diagnosticsCollector = QuickFeedbackDiagnosticsCollector( + tabCountProvider: windowControllersManager, + launchDate: appLaunchDate + ) + return QuickFeedbackService( + diagnosticsCollector: diagnosticsCollector, + firePublisher: fireCoordinator.fireViewModel.fire.burningDataPublisher + ) + }() + let tabCrashAggregator = TabCrashAggregator() let windowControllersManager: WindowControllersManager let tabSuspensionService: TabSuspensionService diff --git a/macOS/DuckDuckGo/Application/AppVersionModel.swift b/macOS/DuckDuckGo/Application/AppVersionModel.swift index b795e389724..c9332c95019 100644 --- a/macOS/DuckDuckGo/Application/AppVersionModel.swift +++ b/macOS/DuckDuckGo/Application/AppVersionModel.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppKit import Common import PrivacyConfig @@ -71,4 +72,13 @@ final class AppVersionModel { } return label } + + /// Distribution channel label (e.g. "App Store", "DMG", "DMG Alpha"). + var distributionLabel: String { + var label = NSApp.isSandboxed ? "App Store" : "DMG" + if buildType.isAlphaBuild { + label.append(" Alpha") + } + return label + } } diff --git a/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollector.swift b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollector.swift new file mode 100644 index 00000000000..710490fdb2f --- /dev/null +++ b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollector.swift @@ -0,0 +1,113 @@ +// +// QuickFeedbackDiagnosticsCollector.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Foundation +import Metal + +final class QuickFeedbackDiagnosticsCollector { + + private weak var tabCountProvider: TabCountProviding? + private let launchDate: Date + + init(tabCountProvider: TabCountProviding? = nil, launchDate: Date = Date()) { + self.tabCountProvider = tabCountProvider + self.launchDate = launchDate + } + + func collectDiagnostics() -> String { + var lines = [String]() + + lines.append("--- Diagnostics (auto-collected) ---") + + let appVersionModel = AppVersionModel() + lines.append("App Version: \(appVersionModel.versionLabelShort) (\(appVersionModel.distributionLabel))") + + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + lines.append("macOS: \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)") + + #if arch(arm64) + lines.append("Architecture: Apple Silicon (arm64)") + #elseif arch(x86_64) + lines.append("Architecture: Intel (x86_64)") + #else + lines.append("Architecture: unknown") + #endif + + lines.append("GPU: \(gpuSummary())") + lines.append("Memory: \(memorySummary())") + lines.append("Disk: \(diskSummary())") + + if let provider = tabCountProvider { + lines.append("Tabs: \(provider.tabCount) tabs / \(provider.windowCount) windows") + } + + lines.append("Session: \(sessionLength())") + + return lines.joined(separator: "\n") + } + + // MARK: - Private + + private func gpuSummary() -> String { + let devices = MTLCopyAllDevices() + guard !devices.isEmpty else { return "unknown" } + return devices.map(\.name).joined(separator: ", ") + } + + private func memorySummary() -> String { + let physicalGB = Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824.0 + + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + if result == KERN_SUCCESS { + let browserMB = info.resident_size / (1024 * 1024) + return String(format: "%llu MB browser, %.0f GB total", browserMB, physicalGB) + } + return String(format: "%.0f GB total", physicalGB) + } + + private func diskSummary() -> String { + guard let homeURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first, + let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let freeBytes = values.volumeAvailableCapacityForImportantUsage else { + return "unknown" + } + let freeGB = freeBytes / (1024 * 1024 * 1024) + return "\(freeGB) GB free" + } + + private func sessionLength() -> String { + let uptime = Date().timeIntervalSince(launchDate) + if uptime < 60 { return "under a minute" } + if uptime < 3600 { return "\(Int(uptime / 60)) minutes" } + if uptime < 86400 { return "\(Int(uptime / 3600)) hours" } + return "\(Int(uptime / 86400)) days" + } +} + +protocol TabCountProviding: AnyObject { + var tabCount: Int { get } + var windowCount: Int { get } +} diff --git a/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackService.swift b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackService.swift new file mode 100644 index 00000000000..d44f5753aeb --- /dev/null +++ b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackService.swift @@ -0,0 +1,276 @@ +// +// QuickFeedbackService.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Combine +import Common +import os.log +import WebKit + +@MainActor +final class QuickFeedbackService: NSObject { + + private var windowController: QuickFeedbackWindowController? + private var screenshotData: Data? + private let diagnosticsCollector: QuickFeedbackDiagnosticsCollector + + private let dataStore: WKWebsiteDataStore + private var cancellables = Set() + + private static let asanaFormHost = "form.asana.com" + private static let asanaCookieDomain = "asana.com" + private static let feedbackStoreIdentifier = UUID(uuidString: "D1A2B3C4-E5F6-7890-ABCD-EF1234567890")! + + private static let earlyInjectionScript = """ + (function() { + var s = document.createElement('style'); + s.id = 'ddg-form-hider'; + s.textContent = '.WorkRequestsSection { opacity: 0; }'; + (document.head || document.documentElement).appendChild(s); + + setTimeout(function() { + var h = document.getElementById('ddg-form-hider'); + if (h && h.textContent.indexOf('opacity: 0') !== -1) { + h.textContent = '.WorkRequestsSection { opacity: 1; }'; + } + }, 8000); + + var origAdd = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function(type, fn, opts) { + if (type === 'beforeunload') return; + return origAdd.call(this, type, fn, opts); + }; + window.addEventListener('beforeunload', function(e) { e.stopImmediatePropagation(); delete e.returnValue; }, true); + window.onbeforeunload = null; + Object.defineProperty(window, 'onbeforeunload', { get: function() { return null; }, set: function() {} }); + })(); + """ + + init( + diagnosticsCollector: QuickFeedbackDiagnosticsCollector, + firePublisher: AnyPublisher + ) { + self.diagnosticsCollector = diagnosticsCollector + // macOS 14+ uses an isolated persistent store so Fire doesn't clear + // the Asana session. On older macOS (no forIdentifier API), we fall + // back to .default() where Fire will clear cookies. Acceptable since + // internal users on macOS < 14 are extremely rare. + if #available(macOS 14.0, *) { + self.dataStore = WKWebsiteDataStore(forIdentifier: Self.feedbackStoreIdentifier) + } else { + self.dataStore = .default() + } + + super.init() + + firePublisher + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.forceClosePopup() + } + .store(in: &cancellables) + } + + func openFeedbackPopup(from window: NSWindow? = nil) { + captureScreenshot(from: window) + + if let existing = windowController { + existing.window?.makeKeyAndOrderFront(nil) + navigateToForm() + return + } + + let controller = createWindowController() + windowController = controller + + controller.window?.makeKeyAndOrderFront(nil) + + Task { + await copyAsanaCookiesFromDefaultStore() + navigateToForm() + } + } + + private func createWindowController() -> QuickFeedbackWindowController { + let config = WKWebViewConfiguration() + config.websiteDataStore = dataStore + config.processPool = WKProcessPool() + + let userScript = WKUserScript( + source: Self.earlyInjectionScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + config.userContentController.addUserScript(userScript) + let controller = QuickFeedbackWindowController(webViewConfiguration: config) + controller.webView.navigationDelegate = self + controller.window?.delegate = self + controller.onSignOutRequested = { [weak self] in + self?.signOut() + } + + return controller + } + + private func navigateToForm() { + guard let webView = windowController?.webView else { return } + let request = URLRequest(url: .internalFeedbackForm) + webView.load(request) + } + + private func hidePopup() { + windowController?.window?.orderOut(nil) + screenshotData = nil + } + + private func forceClosePopup() { + windowController?.window?.orderOut(nil) + windowController = nil + screenshotData = nil + } + + private func signOut() { + windowController?.setSignOutVisible(false) + + if dataStore === WKWebsiteDataStore.default() { + dataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { [weak self] records in + let asanaRecords = records.filter { $0.displayName.contains("asana") } + self?.dataStore.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: asanaRecords) { + Task { @MainActor [weak self] in + self?.navigateToForm() + } + } + } + } else { + dataStore.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: .distantPast) { [weak self] in + Task { @MainActor [weak self] in + self?.navigateToForm() + } + } + } + } + + // MARK: - Cookie Sync + + private func copyAsanaCookiesFromDefaultStore() async { + guard dataStore !== WKWebsiteDataStore.default() else { return } + + let defaultCookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() + let asanaCookies = defaultCookies.filter { $0.domain.hasSuffix(Self.asanaCookieDomain) } + + for cookie in asanaCookies { + await dataStore.httpCookieStore.setCookie(cookie) + } + } + + // MARK: - Screenshot + + private func captureScreenshot(from window: NSWindow?) { + guard let targetWindow = window ?? NSApp.mainWindow else { + screenshotData = nil + return + } + + let windowID = CGWindowID(targetWindow.windowNumber) + guard let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowID, + [.boundsIgnoreFraming, .nominalResolution] + ) else { + screenshotData = nil + return + } + + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + screenshotData = bitmapRep.representation(using: .png, properties: [:]) + } + + // MARK: - JS Injection + + private func injectQuickModeScript() { + guard let webView = windowController?.webView else { return } + + let diagnostics = diagnosticsCollector.collectDiagnostics() + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + + let appVersionModel = AppVersionModel() + + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + + guard let url = Bundle.main.url(forResource: "internal-feedback-autofiller", withExtension: "js"), + let template = try? String(contentsOf: url, encoding: .utf8) else { + Logger.general.error("Failed to load internal-feedback-autofiller.js from bundle") + return + } + + let screenshotBase64 = screenshotData?.base64EncodedString() ?? "" + + let script = template + .replacingOccurrences(of: "%OS_VERSION%", with: osVersionString) + .replacingOccurrences(of: "%APP_VERSION%", with: "\(appVersionModel.versionLabelShort) (\(appVersionModel.distributionLabel))") + .replacingOccurrences(of: "%QUICK_MODE%", with: "true") + .replacingOccurrences(of: "%DIAGNOSTICS%", with: diagnostics) + .replacingOccurrences(of: "%SCREENSHOT_BASE64%", with: screenshotBase64) + + webView.evaluateJavaScript(script) { _, error in + if let error { + Logger.general.error("Quick feedback JS evaluation failed: \(error.localizedDescription)") + } + } + } +} + +// MARK: - WKNavigationDelegate + +extension QuickFeedbackService: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let url = webView.url else { return } + + let isAsanaForm = url.host == Self.asanaFormHost + + if !isAsanaForm { + windowController?.setSignOutVisible(false) + return + } + + windowController?.setSignOutVisible(true) + injectQuickModeScript() + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + return .allow + } +} + +// MARK: - WKScriptMessageHandler + +// MARK: - NSWindowDelegate + +extension QuickFeedbackService: NSWindowDelegate { + + func windowShouldClose(_ sender: NSWindow) -> Bool { + hidePopup() + return false + } +} diff --git a/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackTipController.swift b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackTipController.swift new file mode 100644 index 00000000000..8f40f5b4024 --- /dev/null +++ b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackTipController.swift @@ -0,0 +1,186 @@ +// +// QuickFeedbackTipController.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Foundation + +@MainActor +final class QuickFeedbackTipController { + + private static let messages = [ + "Dax wants YOU to report problems!", + "Only YOU can prevent regressions!", + "Spotted a bug? Dax wants to hear about it!", + "Help Dax squash bugs, share your feedback!", + "Deliver delight the Dax way, report a problem today!", + ] + + private static let lastShownKey = "feedbackTip.lastShown" + private static let buttonClickedKey = "feedbackTip.buttonClicked" + + #if DEBUG + private static let showDelay: TimeInterval = 3 + private static let preClickInterval: TimeInterval = 30 + private static let postClickInterval: TimeInterval = 60 + private static let autoDismissDelay: TimeInterval = 5 + #else + private static let showDelay: TimeInterval = 3 + private static let preClickInterval: TimeInterval = 86400 // 24 hours + private static let postClickInterval: TimeInterval = 604800 // 7 days + private static let autoDismissDelay: TimeInterval = 5 + #endif + + private var popover: NSPopover? + private var autoDismissTimer: Timer? + private weak var anchorView: NSView? + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func scheduleIfNeeded(anchoredTo view: NSView) { + anchorView = view + + guard shouldShow() else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.showDelay) { [weak self] in + self?.showTip() + } + } + + func recordButtonClick() { + defaults.set(true, forKey: Self.buttonClickedKey) + dismissTip() + } + + func dismissTip() { + autoDismissTimer?.invalidate() + autoDismissTimer = nil + popover?.close() + popover = nil + } + + private func shouldShow() -> Bool { + let lastShown = defaults.double(forKey: Self.lastShownKey) + guard lastShown > 0 else { return true } + + let hasClicked = defaults.bool(forKey: Self.buttonClickedKey) + let interval = hasClicked ? Self.postClickInterval : Self.preClickInterval + let elapsed = Date().timeIntervalSince1970 - lastShown + return elapsed >= interval + } + + private func showTip() { + guard let anchor = anchorView, anchor.window != nil else { return } + guard shouldShow() else { return } + + let message = Self.messages.randomElement() ?? Self.messages[0] + let viewController = QuickFeedbackTipViewController(message: message) { [weak self] in + self?.dismissTip() + } + + let tip = NSPopover() + tip.contentViewController = viewController + tip.behavior = .semitransient + tip.animates = true + popover = tip + + tip.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY) + + defaults.set(Date().timeIntervalSince1970, forKey: Self.lastShownKey) + + autoDismissTimer = Timer.scheduledTimer(withTimeInterval: Self.autoDismissDelay, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.dismissTip() + } + } + } +} + +// MARK: - Tip Content View Controller + +private final class QuickFeedbackTipViewController: NSViewController { + + private let message: String + private let onDismiss: () -> Void + + init(message: String, onDismiss: @escaping () -> Void) { + self.message = message + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 60)) + + let daxIcon = NSImageView() + daxIcon.translatesAutoresizingMaskIntoConstraints = false + daxIcon.image = NSImage(named: "OnboardingDax") + daxIcon.imageScaling = .scaleProportionallyDown + + let label = NSTextField(wrappingLabelWithString: message) + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13) + label.textColor = .labelColor + label.isEditable = false + label.isSelectable = false + label.drawsBackground = false + label.isBordered = false + + let dismissButton = NSButton() + dismissButton.translatesAutoresizingMaskIntoConstraints = false + dismissButton.bezelStyle = .inline + dismissButton.isBordered = false + dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss") + dismissButton.imageScaling = .scaleProportionallyDown + dismissButton.target = self + dismissButton.action = #selector(dismissClicked) + + container.addSubview(daxIcon) + container.addSubview(label) + container.addSubview(dismissButton) + + NSLayoutConstraint.activate([ + daxIcon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + daxIcon.centerYAnchor.constraint(equalTo: container.centerYAnchor), + daxIcon.widthAnchor.constraint(equalToConstant: 28), + daxIcon.heightAnchor.constraint(equalToConstant: 28), + + label.leadingAnchor.constraint(equalTo: daxIcon.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: dismissButton.leadingAnchor, constant: -4), + label.centerYAnchor.constraint(equalTo: container.centerYAnchor), + + dismissButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + dismissButton.centerYAnchor.constraint(equalTo: container.centerYAnchor), + dismissButton.widthAnchor.constraint(equalToConstant: 20), + dismissButton.heightAnchor.constraint(equalToConstant: 20), + ]) + + view = container + } + + @objc private func dismissClicked() { + onDismiss() + } +} diff --git a/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackWindowController.swift b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackWindowController.swift new file mode 100644 index 00000000000..889b956e204 --- /dev/null +++ b/macOS/DuckDuckGo/Feedback/QuickFeedback/QuickFeedbackWindowController.swift @@ -0,0 +1,116 @@ +// +// QuickFeedbackWindowController.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import WebKit + +@MainActor +final class QuickFeedbackWindowController: NSWindowController { + + let webView: WKWebView + private let signOutBar = NSView() + private let signOutButton = NSButton() + private var signOutBarHeightConstraint: NSLayoutConstraint! + + var onSignOutRequested: (() -> Void)? + + init(webViewConfiguration: WKWebViewConfiguration) { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 700), + styleMask: [.titled, .closable, .resizable, .utilityWindow], + backing: .buffered, + defer: true + ) + panel.title = "Internal Feedback" + panel.level = .floating + panel.isReleasedWhenClosed = false + panel.minSize = NSSize(width: 450, height: 500) + panel.center() + + webView = WKWebView(frame: .zero, configuration: webViewConfiguration) + webView.translatesAutoresizingMaskIntoConstraints = false + + super.init(window: panel) + + setupContentView(in: panel) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSignOutVisible(_ visible: Bool) { + signOutBar.isHidden = !visible + signOutBarHeightConstraint.constant = visible ? 28 : 0 + } + + private func setupContentView(in panel: NSPanel) { + let contentView = NSView() + contentView.translatesAutoresizingMaskIntoConstraints = false + + signOutBar.translatesAutoresizingMaskIntoConstraints = false + signOutBar.isHidden = true + + signOutButton.translatesAutoresizingMaskIntoConstraints = false + signOutButton.title = "Sign out" + signOutButton.bezelStyle = .inline + signOutButton.isBordered = false + signOutButton.font = .systemFont(ofSize: 12) + signOutButton.contentTintColor = .secondaryLabelColor + signOutButton.target = self + signOutButton.action = #selector(signOutClicked) + + let separator = NSBox() + separator.translatesAutoresizingMaskIntoConstraints = false + separator.boxType = .separator + + signOutBar.addSubview(signOutButton) + signOutBar.addSubview(separator) + + contentView.addSubview(signOutBar) + contentView.addSubview(webView) + + panel.contentView = contentView + + signOutBarHeightConstraint = signOutBar.heightAnchor.constraint(equalToConstant: 0) + + NSLayoutConstraint.activate([ + signOutBar.topAnchor.constraint(equalTo: contentView.topAnchor), + signOutBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + signOutBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + signOutBarHeightConstraint, + + signOutButton.trailingAnchor.constraint(equalTo: signOutBar.trailingAnchor, constant: -8), + signOutButton.centerYAnchor.constraint(equalTo: signOutBar.centerYAnchor), + + separator.leadingAnchor.constraint(equalTo: signOutBar.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: signOutBar.trailingAnchor), + separator.bottomAnchor.constraint(equalTo: signOutBar.bottomAnchor), + + webView.topAnchor.constraint(equalTo: signOutBar.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + + @objc private func signOutClicked() { + onSignOutRequested?() + } +} diff --git a/macOS/DuckDuckGo/Feedback/Resources/internal-feedback-autofiller.js b/macOS/DuckDuckGo/Feedback/Resources/internal-feedback-autofiller.js index 2bf51409a1d..8170278364c 100644 --- a/macOS/DuckDuckGo/Feedback/Resources/internal-feedback-autofiller.js +++ b/macOS/DuckDuckGo/Feedback/Resources/internal-feedback-autofiller.js @@ -1,3 +1,7 @@ +var quickMode = %QUICK_MODE%; +var diagnosticsText = '%DIAGNOSTICS%'; +var screenshotBase64 = '%SCREENSHOT_BASE64%'; + function openDropdown(label) { const dropdown = document.querySelector(`[aria-label^="${label}"]`); if (dropdown) { @@ -40,7 +44,7 @@ function setInputAfterLabel(tag, labelText, value) { if (value) { setInputValue(input, value); } else { - input.focus() + input.focus(); } } else { console.error(`${tag} field after label "${labelText}" not found.`); @@ -72,10 +76,314 @@ function waitForElement(tag, text, timeout = 5000) { }); } -function fillOutForm() { - openDropdown('Which product area or team does this feedback relate to?'); - selectOption('Native Apps'); +function hideFormQuestion(labelText) { + const labels = Array.from(document.querySelectorAll('label')); + const match = labels.find(l => l.textContent.trim().startsWith(labelText)); + if (match) { + const question = match.closest('.WorkRequestsFieldRow'); + if (question) { + question.style.display = 'none'; + } + } +} + +function addEmailFieldPadding() { + var emailLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { + var text = l.textContent.trim().toLowerCase(); + return text.startsWith('your email') || text.startsWith('email'); + }); + if (!emailLabel) return; + var emailRow = emailLabel.closest('.WorkRequestsFieldRow'); + if (emailRow) { + emailRow.style.marginTop = '16px'; + } +} + +function hideIrrelevantFields() { + const fieldsToHide = [ + 'Which product area or team', + 'Which platform?', + 'Which macOS version', + 'Which version of the DuckDuckGo', + 'Describe the issue', + '[IGNORE IF NO]', + 'If available, attach any screenshots', + 'Which downstream provider', + 'Ban type', + ]; + fieldsToHide.forEach(hideFormQuestion); + + addEmailFieldPadding(); + moveSubmitButtonUnderDescription(); + injectDiagnosticsSection(); + injectScreenshotSection(); + hookSubmitForDiagnostics(); + + var hider = document.getElementById('ddg-form-hider'); + if (hider) { + hider.textContent = '.WorkRequestsSection { opacity: 1; transition: opacity 0.15s; }'; + } + + setTimeout(function() { + var descLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { return l.textContent.trim().startsWith('Please describe your issue/feedback'); }); + if (descLabel) { + var descRow = descLabel.closest('.WorkRequestsFieldRow'); + if (descRow) { + descLabel.textContent = 'Please provide your feedback here!'; + var helperText = descRow.querySelector('.WorkRequestsFieldRow-helpText, .WorkRequestsFieldRow-description'); + if (!helperText) { + var spans = descRow.querySelectorAll('span, p, div'); + for (var i = 0; i < spans.length; i++) { + if (spans[i].textContent.trim().startsWith('What was the expectation')) { + helperText = spans[i]; + break; + } + } + } + if (helperText) { + helperText.textContent = 'An engineer will contact you via Asana if more information is required or helpful. Thank you!'; + } + var textarea = descRow.querySelector('textarea'); + if (textarea) { + textarea.click(); + textarea.focus(); + } + } + } + }, 300); +} + +function moveSubmitButtonUnderDescription() { + var descLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { return l.textContent.trim().startsWith('Please describe your issue/feedback'); }); + if (!descLabel) return; + var descRow = descLabel.closest('.WorkRequestsFieldRow'); + if (!descRow) return; + + var realSubmitArea = document.querySelector('.WorkRequestsSubmissionForm-submitButtonAndError'); + if (!realSubmitArea) return; + + var realSubmitBtn = document.querySelector('.WorkRequestsSubmissionForm-submitButton'); + if (!realSubmitBtn) return; + + realSubmitArea.style.display = 'none'; + + var clone = realSubmitBtn.cloneNode(true); + clone.style.cssText = 'margin: 12px 0 0; width: 100%; cursor: pointer;'; + clone.id = 'ddg-submit-clone'; + + clone.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + realSubmitBtn.click(); + }); + + descRow.parentNode.insertBefore(clone, descRow.nextSibling); +} + +function injectDiagnosticsSection() { + var submitClone = document.getElementById('ddg-submit-clone'); + var anchor = submitClone; + + if (!anchor) { + var labels = Array.from(document.querySelectorAll('label')); + var descLabel = labels.find(function(l) { + return l.textContent.trim().startsWith('Please describe your issue/feedback'); + }); + if (descLabel) anchor = descLabel.closest('.WorkRequestsFieldRow'); + } + if (!anchor) return; + + var section = document.createElement('div'); + section.id = 'ddg-diagnostics-section'; + section.style.cssText = 'margin: 12px 24px 0; padding: 0;'; + + var headerRow = document.createElement('div'); + headerRow.style.cssText = 'display: flex; align-items: center; gap: 8px;'; + + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.id = 'ddg-include-diagnostics'; + cb.checked = true; + headerRow.appendChild(cb); + + var cbLabel = document.createElement('label'); + cbLabel.setAttribute('for', 'ddg-include-diagnostics'); + cbLabel.textContent = 'Include system diagnostics'; + cbLabel.style.cssText = 'font-size: 14px; cursor: pointer;'; + headerRow.appendChild(cbLabel); + + section.appendChild(headerRow); + + var details = document.createElement('details'); + details.style.cssText = 'margin-top: 6px;'; + + var summary = document.createElement('summary'); + summary.textContent = 'View diagnostics'; + summary.style.cssText = 'font-size: 12px; color: #666; cursor: pointer; user-select: none;'; + details.appendChild(summary); + + var pre = document.createElement('pre'); + pre.textContent = diagnosticsText.replace(/\\n/g, '\n'); + pre.style.cssText = 'font-size: 11px; background: #f5f5f5; padding: 10px; border-radius: 4px; margin: 6px 0 0; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto;'; + details.appendChild(pre); + + section.appendChild(details); + + anchor.parentNode.insertBefore(section, anchor.nextSibling); +} + +function injectScreenshotSection() { + if (!screenshotBase64 || screenshotBase64 === '') return; + + var anchor = document.getElementById('ddg-diagnostics-section') || document.getElementById('ddg-submit-clone'); + if (!anchor) return; + + var section = document.createElement('div'); + section.id = 'ddg-screenshot-section'; + section.style.cssText = 'margin: 12px 24px 0; padding: 0;'; + + var headerRow = document.createElement('div'); + headerRow.style.cssText = 'display: flex; align-items: center; gap: 8px;'; + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.id = 'ddg-include-screenshot'; + cb.checked = false; + headerRow.appendChild(cb); + + var cbLabel = document.createElement('label'); + cbLabel.setAttribute('for', 'ddg-include-screenshot'); + cbLabel.textContent = 'Include screenshot'; + cbLabel.style.cssText = 'font-size: 14px; cursor: pointer;'; + headerRow.appendChild(cbLabel); + + section.appendChild(headerRow); + + var warning = document.createElement('div'); + warning.style.cssText = 'font-size: 12px; color: #856404; background: #fff3cd; padding: 6px 10px; border-radius: 4px; margin: 6px 0 8px; display: none;'; + warning.textContent = '\u26A0 This screenshot may contain private information. Please review carefully before including it.'; + section.appendChild(warning); + + cb.addEventListener('change', function() { + warning.style.display = cb.checked ? '' : 'none'; + }); + + var img = document.createElement('img'); + img.src = 'data:image/png;base64,' + screenshotBase64; + img.style.cssText = 'max-width: 100%; max-height: 150px; margin-top: 6px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;'; + img.title = 'Click to enlarge'; + + img.addEventListener('click', function() { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;padding:16px;box-sizing:border-box;'; + + var closeBtn = document.createElement('button'); + closeBtn.textContent = '\u2715 Close'; + closeBtn.style.cssText = 'position:absolute;top:12px;right:16px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);color:white;font-size:14px;padding:6px 14px;border-radius:6px;cursor:pointer;'; + closeBtn.addEventListener('click', function() { overlay.remove(); }); + overlay.appendChild(closeBtn); + + var bigImg = document.createElement('img'); + bigImg.src = img.src; + bigImg.style.cssText = 'max-width:100%;max-height:calc(100% - 40px);object-fit:contain;border-radius:4px;'; + overlay.appendChild(bigImg); + overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); }); + document.body.appendChild(overlay); + }); + + section.appendChild(img); + + anchor.parentNode.insertBefore(section, anchor.nextSibling); +} + +function attachScreenshotToForm() { + var img = document.querySelector('#ddg-screenshot-section img'); + if (!img || !img.src.startsWith('data:image/png;base64,')) return; + + var base64 = img.src.split(',')[1]; + var binary = atob(base64); + var array = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + var file = new File([array], 'screenshot.png', { type: 'image/png' }); + + var attachLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { return l.textContent.trim().startsWith('If available, attach'); }); + if (!attachLabel) return; + + var attachRow = attachLabel.closest('.WorkRequestsFieldRow'); + if (!attachRow) return; + + attachRow.style.display = ''; + var fileInput = attachRow.querySelector('input[type="file"]'); + if (fileInput) { + var dt = new DataTransfer(); + dt.items.add(file); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + } + setTimeout(function() { attachRow.style.display = 'none'; }, 200); +} + +function hookSubmitForDiagnostics() { + var submitBtn = document.querySelector('.WorkRequestsSubmissionForm-submitButton'); + if (!submitBtn) return; + + submitBtn.addEventListener('click', function() { + var descLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { + var t = l.textContent.trim(); + return t.startsWith('Please provide your feedback') || t.startsWith('Please describe your issue/feedback'); + }); + if (!descLabel) return; + var descRow = descLabel.closest('.WorkRequestsFieldRow'); + if (!descRow) return; + var textarea = descRow.querySelector('textarea'); + if (!textarea) return; + + var diagsSentinel = '--- Diagnostics (auto-collected) ---'; + var rawText = textarea.value; + var sentinelIdx = rawText.indexOf(diagsSentinel); + var userText = (sentinelIdx !== -1 ? rawText.substring(0, sentinelIdx) : rawText).trim(); + + var titleLabel = Array.from(document.querySelectorAll('label')) + .find(function(l) { return l.textContent.trim().startsWith('Describe the issue'); }); + if (titleLabel) { + var titleInput = document.getElementById(titleLabel.getAttribute('for')); + if (titleInput) { + var titleText = userText.length > 100 ? userText.substring(0, 100) + '...' : userText; + if (!titleText) titleText = 'Feedback (no description)'; + setInputValue(titleInput, titleText); + } + } + + var diagsCb = document.getElementById('ddg-include-diagnostics'); + if (diagsCb && diagsCb.checked && diagnosticsText) { + var decodedDiags = diagnosticsText.replace(/\\n/g, '\n'); + var combined = userText + ? userText + '\n\n' + decodedDiags + : decodedDiags; + + var setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, 'value' + ).set; + setter.call(textarea, combined); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + + var screenshotCb = document.getElementById('ddg-include-screenshot'); + if (screenshotCb && screenshotCb.checked) { + attachScreenshotToForm(); + } + + }, true); +} + +function fillOutFormAfterNativeAppsSelected() { waitForElement('label', 'Which platform?') .then(() => { openDropdown('Which platform?'); @@ -85,14 +393,33 @@ function fillOutForm() { .then(() => { setInputAfterLabel('label', 'Which macOS version?', '%OS_VERSION%'); setInputAfterLabel('label', 'Which version of the DuckDuckGo Browser?', '%APP_VERSION%'); - // set with no value -> just focus it - setInputAfterLabel('label', 'Asana Task Title'); + + if (quickMode) { + setTimeout(hideIrrelevantFields, 50); + } else { + setInputAfterLabel('label', 'Asana Task Title'); + } }) .catch(error => console.error('"Which macOS version?" label not found:', error)); }) .catch(error => console.error('"Which platform?" label not found:', error)); } +function handleNativeAppsDropdown() { + openDropdown('Which product area or team does this feedback relate to?'); + selectOption('Native Apps & Extensions'); + + const observer = new MutationObserver(() => { + const selected = document.querySelector('[aria-label^="Which product area or team does this feedback relate to?"]'); + if (selected && selected.textContent.trim().includes('Native Apps')) { + observer.disconnect(); + fillOutFormAfterNativeAppsSelected(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); +} + waitForElement('h1', 'Internal Product Feedback Form') - .then(_ => fillOutForm()) + .then(_ => handleNativeAppsDropdown()) .catch(_ => console.error('Internal Product Feedback Form is not loaded after 5s')); diff --git a/macOS/DuckDuckGo/Menus/MainMenuActions.swift b/macOS/DuckDuckGo/Menus/MainMenuActions.swift index b606fa914c4..a30fa4029a0 100644 --- a/macOS/DuckDuckGo/Menus/MainMenuActions.swift +++ b/macOS/DuckDuckGo/Menus/MainMenuActions.swift @@ -230,7 +230,7 @@ extension AppDelegate { @objc func openFeedback(_ sender: Any?) { DispatchQueue.main.async { if self.internalUserDecider.isInternalUser { - Application.appDelegate.windowControllersManager.showTab(with: .url(.internalFeedbackForm, source: .ui)) + self.quickFeedbackService.openFeedbackPopup(from: NSApp.mainWindow) } else { Application.appDelegate.openRequestANewFeature(nil) } @@ -268,7 +268,7 @@ extension AppDelegate { @MainActor @objc func openReportABrowserProblem(_ sender: Any?) { guard !self.internalUserDecider.isInternalUser else { - Application.appDelegate.windowControllersManager.showTab(with: .url(.internalFeedbackForm, source: .ui)) + quickFeedbackService.openFeedbackPopup(from: NSApp.mainWindow) return } @@ -333,7 +333,7 @@ extension AppDelegate { @MainActor @objc func openRequestANewFeature(_ sender: Any?) { guard !self.internalUserDecider.isInternalUser else { - Application.appDelegate.windowControllersManager.showTab(with: .url(.internalFeedbackForm, source: .ui)) + quickFeedbackService.openFeedbackPopup(from: NSApp.mainWindow) return } diff --git a/macOS/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/macOS/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 5f265f982cb..d312abdb2e5 100644 --- a/macOS/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/macOS/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -21,6 +21,7 @@ import BrowserServicesKit import Cocoa import Combine import Common +import DesignResourcesKitIcons import Freemium import History import NetworkProtectionIPC @@ -77,6 +78,10 @@ final class NavigationBarViewController: NSViewController { @IBOutlet private var backgroundColorView: MouseOverView! @IBOutlet private var backgroundBaseColorView: ColorView! + private var feedbackButton: MouseOverButton? + private var feedbackButtonSpacer: NSView? + private var feedbackTipController: QuickFeedbackTipController? + private var internalUserCancellable: AnyCancellable? private var fireWindowBackgroundView: NSImageView? @IBOutlet private var goBackButtonWidthConstraint: NSLayoutConstraint! @IBOutlet private var goBackButtonHeightConstraint: NSLayoutConstraint! @@ -466,6 +471,7 @@ final class NavigationBarViewController: NSViewController { setupNavigationButtons() setupOverflowMenu() setupNetworkProtectionButton() + setupQuickFeedbackButton() subscribeToThemeChanges() listenToPasswordManagerNotifications() @@ -1977,10 +1983,84 @@ extension NavigationBarViewController: NSMenuDelegate { withDelegate: networkProtectionButtonModel) } - /// Sets up the VPN button. - /// - /// This method should be run just once during the lifecycle of this view. - /// . + // MARK: - Quick Feedback Button + + private func setupQuickFeedbackButton() { + guard !isInPopUpWindow else { return } + + let internalUserDecider = NSApp.delegateTyped.internalUserDecider + + if internalUserDecider.isInternalUser { + addQuickFeedbackButton() + } + + internalUserCancellable = internalUserDecider.isInternalUserPublisher + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] isInternal in + if isInternal { + self?.addQuickFeedbackButton() + } else { + self?.removeQuickFeedbackButton() + } + } + } + + private func addQuickFeedbackButton() { + guard feedbackButton == nil else { return } + + let button = MouseOverButton(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .shadowlessSquare + button.isBordered = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.toolTip = "Send Internal Feedback" + button.target = self + button.action = #selector(quickFeedbackButtonClicked) + + let icon = DesignSystemImages.Color.Size16.feedback + icon.isTemplate = false + button.image = icon + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 28), + button.heightAnchor.constraint(equalToConstant: 28), + ]) + + menuButtons.insertArrangedSubview(button, at: 0) + + let spacer = NSView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.widthAnchor.constraint(equalToConstant: 6).isActive = true + menuButtons.insertArrangedSubview(spacer, at: 1) + + feedbackButton = button + feedbackButtonSpacer = spacer + + let tipController = QuickFeedbackTipController() + feedbackTipController = tipController + + DispatchQueue.main.async { [weak tipController, weak button] in + guard let button else { return } + tipController?.scheduleIfNeeded(anchoredTo: button) + } + } + + private func removeQuickFeedbackButton() { + feedbackTipController?.dismissTip() + feedbackTipController = nil + feedbackButton?.removeFromSuperview() + feedbackButton = nil + feedbackButtonSpacer?.removeFromSuperview() + feedbackButtonSpacer = nil + } + + @objc private func quickFeedbackButtonClicked(_ sender: Any?) { + feedbackTipController?.recordButtonClick() + Application.appDelegate.quickFeedbackService.openFeedbackPopup(from: view.window) + } + private func setupNetworkProtectionButton() { guard !isInPopUpWindow else { networkProtectionButton.isHidden = true diff --git a/macOS/DuckDuckGo/Tab/TabExtensions/InternalFeedbackFormTabExtension.swift b/macOS/DuckDuckGo/Tab/TabExtensions/InternalFeedbackFormTabExtension.swift index c4272be4898..91526aa3d7d 100644 --- a/macOS/DuckDuckGo/Tab/TabExtensions/InternalFeedbackFormTabExtension.swift +++ b/macOS/DuckDuckGo/Tab/TabExtensions/InternalFeedbackFormTabExtension.swift @@ -42,18 +42,16 @@ final class InternalFeedbackFormUserScript: NSObject, UserScript { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {} - override init() { - let buildType = StandardApplicationBuildType() + init(quickMode: Bool = false, diagnostics: String = "") { let appVersionModel = AppVersionModel() - var distributionType = NSApp.isSandboxed ? "App Store" : "DMG" - if buildType.isAlphaBuild { - distributionType.append(" Alpha") - } do { source = try Self.loadJS("internal-feedback-autofiller", from: .main, withReplacements: [ "%OS_VERSION%": ProcessInfo.processInfo.operatingSystemVersion.description, - "%APP_VERSION%": "\(appVersionModel.versionLabelShort) (\(distributionType))" + "%APP_VERSION%": "\(appVersionModel.versionLabelShort) (\(appVersionModel.distributionLabel))", + "%QUICK_MODE%": quickMode ? "true" : "false", + "%DIAGNOSTICS%": diagnostics, + "%SCREENSHOT_BASE64%": "", ]) super.init() } catch { diff --git a/macOS/DuckDuckGo/Windows/View/WindowControllersManager.swift b/macOS/DuckDuckGo/Windows/View/WindowControllersManager.swift index 15d70df0c4c..3a07cf850ea 100644 --- a/macOS/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/macOS/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -482,7 +482,7 @@ extension WindowControllersManager { /// Shows the non-subscription feedback modal func showFeedbackModal(preselectedFormOption: FeedbackViewController.FormOption? = nil) { if internalUserDecider.isInternalUser { - showTab(with: .url(.internalFeedbackForm, source: .ui)) + Application.appDelegate.quickFeedbackService.openFeedbackPopup(from: NSApp.mainWindow) } else { FeedbackPresenter.presentFeedbackForm(preselectedFormOption: preselectedFormOption) } @@ -678,3 +678,15 @@ extension WindowControllersManager: OnboardingNavigating { mainVC.navigationBarViewController.addressBarViewController?.addressBarTextField.makeMeFirstResponder() } } + +extension WindowControllersManager: TabCountProviding { + var tabCount: Int { + mainWindowControllers.reduce(0) { total, controller in + total + controller.mainViewController.tabCollectionViewModel.allTabsCount + } + } + + var windowCount: Int { + mainWindowControllers.count + } +} diff --git a/macOS/UnitTests/Feedback/QuickFeedback/AppVersionModelDistributionLabelTests.swift b/macOS/UnitTests/Feedback/QuickFeedback/AppVersionModelDistributionLabelTests.swift new file mode 100644 index 00000000000..f3b4c082700 --- /dev/null +++ b/macOS/UnitTests/Feedback/QuickFeedback/AppVersionModelDistributionLabelTests.swift @@ -0,0 +1,71 @@ +// +// AppVersionModelDistributionLabelTests.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class AppVersionModelDistributionLabelTests: XCTestCase { + + // MARK: - Alpha Build + + func testWhenAlphaBuildThenDistributionLabelContainsAlpha() { + let buildType = ApplicationBuildTypeMock() + buildType.isAlphaBuild = true + + let model = AppVersionModel(buildType: buildType) + + XCTAssertTrue(model.distributionLabel.contains("Alpha"), + "Alpha builds should include 'Alpha' in the distribution label") + } + + // MARK: - Non-Alpha Build + + func testWhenNotAlphaBuildThenDistributionLabelDoesNotContainAlpha() { + let buildType = ApplicationBuildTypeMock() + buildType.isAlphaBuild = false + + let model = AppVersionModel(buildType: buildType) + + XCTAssertFalse(model.distributionLabel.contains("Alpha"), + "Non-alpha builds should not include 'Alpha' in the distribution label") + } + + // MARK: - Distribution Channel + + func testWhenNotAlphaBuildThenDistributionLabelIsDMGOrAppStore() { + let buildType = ApplicationBuildTypeMock() + buildType.isAlphaBuild = false + + let model = AppVersionModel(buildType: buildType) + + let validLabels = ["DMG", "App Store"] + XCTAssertTrue(validLabels.contains(model.distributionLabel), + "Non-alpha label should be exactly 'DMG' or 'App Store', got '\(model.distributionLabel)'") + } + + func testWhenAlphaBuildThenDistributionLabelIsDMGAlphaOrAppStoreAlpha() { + let buildType = ApplicationBuildTypeMock() + buildType.isAlphaBuild = true + + let model = AppVersionModel(buildType: buildType) + + let validLabels = ["DMG Alpha", "App Store Alpha"] + XCTAssertTrue(validLabels.contains(model.distributionLabel), + "Alpha label should be 'DMG Alpha' or 'App Store Alpha', got '\(model.distributionLabel)'") + } +} diff --git a/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollectorTests.swift b/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollectorTests.swift new file mode 100644 index 00000000000..0a8e4f514c1 --- /dev/null +++ b/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackDiagnosticsCollectorTests.swift @@ -0,0 +1,177 @@ +// +// QuickFeedbackDiagnosticsCollectorTests.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class QuickFeedbackDiagnosticsCollectorTests: XCTestCase { + + // MARK: - Header + + func testWhenCollectingDiagnosticsThenOutputStartsWithSentinelHeader() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.hasPrefix("--- Diagnostics (auto-collected) ---")) + } + + // MARK: - Required Fields + + func testWhenCollectingDiagnosticsThenOutputContainsAppVersion() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("App Version:"), "Diagnostics should include the app version line") + } + + func testWhenCollectingDiagnosticsThenOutputContainsMacOSVersion() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let expectedVersion = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + XCTAssertTrue(result.contains("macOS: \(expectedVersion)")) + } + + func testWhenCollectingDiagnosticsThenOutputContainsArchitecture() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Architecture:"), "Diagnostics should include the architecture line") + #if arch(arm64) + XCTAssertTrue(result.contains("Apple Silicon (arm64)")) + #elseif arch(x86_64) + XCTAssertTrue(result.contains("Intel (x86_64)")) + #endif + } + + func testWhenCollectingDiagnosticsThenOutputContainsMemory() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Memory:"), "Diagnostics should include the memory line") + XCTAssertTrue(result.contains("GB total"), "Memory should include total GB") + } + + func testWhenCollectingDiagnosticsThenOutputContainsGPU() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("GPU:"), "Diagnostics should include the GPU line") + } + + func testWhenCollectingDiagnosticsThenOutputContainsDisk() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Disk:"), "Diagnostics should include the disk line") + } + + func testWhenCollectingDiagnosticsThenOutputContainsSession() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Session:"), "Diagnostics should include the session line") + } + + // MARK: - Tab Count + + func testWhenTabCountProviderIsNilThenOutputDoesNotContainTabsLine() { + let collector = QuickFeedbackDiagnosticsCollector(tabCountProvider: nil) + let result = collector.collectDiagnostics() + + XCTAssertFalse(result.contains("Tabs:")) + } + + func testWhenTabCountProviderExistsThenOutputContainsTabsAndWindows() { + let mockProvider = MockTabCountProvider(tabCount: 42, windowCount: 3) + let collector = QuickFeedbackDiagnosticsCollector(tabCountProvider: mockProvider) + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Tabs: 42 tabs / 3 windows")) + } + + func testWhenTabCountIsZeroThenOutputContainsZeroCounts() { + let mockProvider = MockTabCountProvider(tabCount: 0, windowCount: 0) + let collector = QuickFeedbackDiagnosticsCollector(tabCountProvider: mockProvider) + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("Tabs: 0 tabs / 0 windows")) + } + + func testWhenCollectingDiagnosticsThenMemoryIncludesBrowserUsage() { + let collector = QuickFeedbackDiagnosticsCollector() + let result = collector.collectDiagnostics() + + XCTAssertTrue(result.contains("MB browser"), "Memory line should include browser memory usage from mach_task_basic_info") + } + + func testWhenTabCountProviderIsDeallocatedThenOutputDoesNotContainTabsLine() { + var mockProvider: MockTabCountProvider? = MockTabCountProvider(tabCount: 5, windowCount: 2) + let collector = QuickFeedbackDiagnosticsCollector(tabCountProvider: mockProvider!) + mockProvider = nil + + let result = collector.collectDiagnostics() + + XCTAssertFalse(result.contains("Tabs:"), "Should omit tabs when provider is deallocated") + } + + // MARK: - Field Ordering + + func testWhenCollectingDiagnosticsWithProviderThenFieldsAreInExpectedOrder() { + let mockProvider = MockTabCountProvider(tabCount: 3, windowCount: 1) + let collector = QuickFeedbackDiagnosticsCollector(tabCountProvider: mockProvider) + let lines = collector.collectDiagnostics().components(separatedBy: "\n") + + guard lines.count >= 9 else { + XCTFail("Expected at least 9 lines but got \(lines.count)") + return + } + + XCTAssertTrue(lines[0].hasPrefix("--- Diagnostics")) + XCTAssertTrue(lines[1].hasPrefix("App Version:")) + XCTAssertTrue(lines[2].hasPrefix("macOS:")) + XCTAssertTrue(lines[3].hasPrefix("Architecture:")) + XCTAssertTrue(lines[4].hasPrefix("GPU:")) + XCTAssertTrue(lines[5].hasPrefix("Memory:")) + XCTAssertTrue(lines[6].hasPrefix("Disk:")) + XCTAssertTrue(lines[7].hasPrefix("Tabs:")) + XCTAssertTrue(lines[8].hasPrefix("Session:")) + } + + // MARK: - Line Structure + + func testWhenCollectingDiagnosticsThenOutputIsNewlineSeparated() { + let collector = QuickFeedbackDiagnosticsCollector() + let lines = collector.collectDiagnostics().components(separatedBy: "\n") + + XCTAssertGreaterThanOrEqual(lines.count, 8, "Should have at least sentinel + version + OS + arch + GPU + memory + disk + session") + } +} + +// MARK: - Mocks + +private final class MockTabCountProvider: TabCountProviding { + let tabCount: Int + let windowCount: Int + + init(tabCount: Int, windowCount: Int = 1) { + self.tabCount = tabCount + self.windowCount = windowCount + } +} diff --git a/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackTipControllerTests.swift b/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackTipControllerTests.swift new file mode 100644 index 00000000000..a8e3006a516 --- /dev/null +++ b/macOS/UnitTests/Feedback/QuickFeedback/QuickFeedbackTipControllerTests.swift @@ -0,0 +1,178 @@ +// +// QuickFeedbackTipControllerTests.swift +// +// Copyright © 2026 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class QuickFeedbackTipControllerTests: XCTestCase { + + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: "QuickFeedbackTipControllerTests")! + defaults.removePersistentDomain(forName: "QuickFeedbackTipControllerTests") + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "QuickFeedbackTipControllerTests") + defaults = nil + super.tearDown() + } + + // MARK: - First session + + func testWhenNeverShownBeforeThenTipIsScheduled() { + XCTAssertEqual(defaults.double(forKey: "feedbackTip.lastShown"), 0, + "Fresh defaults should have no lastShown timestamp") + + let controller = QuickFeedbackTipController(defaults: defaults) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + + autoreleasepool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true) + window.contentView?.addSubview(anchor) + + controller.scheduleIfNeeded(anchoredTo: anchor) + } + + // lastShown is 0 (never shown), so shouldShow returns true and tip is scheduled. + // The timestamp is not updated synchronously — it updates in showTip after the delay. + XCTAssertEqual(defaults.double(forKey: "feedbackTip.lastShown"), 0, + "Timestamp should not change synchronously when scheduling is deferred") + } + + // MARK: - Cooldown + + func testWhenShownRecentlyThenScheduleIfNeededWillNotSchedule() { + defaults.set(Date().timeIntervalSince1970 - 1, forKey: "feedbackTip.lastShown") + + let controller = QuickFeedbackTipController(defaults: defaults) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + + autoreleasepool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true) + window.contentView?.addSubview(anchor) + + controller.scheduleIfNeeded(anchoredTo: anchor) + } + + let lastShown = defaults.double(forKey: "feedbackTip.lastShown") + XCTAssertGreaterThan(lastShown, 0, "lastShown should retain the prior value") + } + + func testWhenCooldownExceededThenScheduleIfNeededDoesNotReturnEarly() { + // Set lastShown well beyond the cooldown interval (30s in DEBUG) + let oldTimestamp = Date().timeIntervalSince1970 - 60 + defaults.set(oldTimestamp, forKey: "feedbackTip.lastShown") + + let controller = QuickFeedbackTipController(defaults: defaults) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + + autoreleasepool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true) + window.contentView?.addSubview(anchor) + + controller.scheduleIfNeeded(anchoredTo: anchor) + } + + let currentTimestamp = defaults.double(forKey: "feedbackTip.lastShown") + XCTAssertEqual(currentTimestamp, oldTimestamp, accuracy: 0.001, + "Timestamp should not change synchronously when scheduling is deferred") + } + + // MARK: - dismissTip + + func testWhenDismissTipCalledThenPopoverIsClosed() { + let controller = QuickFeedbackTipController(defaults: defaults) + + controller.dismissTip() + } + + func testWhenRecordButtonClickCalledThenTipIsDismissed() { + let controller = QuickFeedbackTipController(defaults: defaults) + + controller.recordButtonClick() + } + + func testWhenRecordButtonClickCalledThenButtonClickedIsPersistedInDefaults() { + let controller = QuickFeedbackTipController(defaults: defaults) + XCTAssertFalse(defaults.bool(forKey: "feedbackTip.buttonClicked")) + + controller.recordButtonClick() + + XCTAssertTrue(defaults.bool(forKey: "feedbackTip.buttonClicked")) + } + + func testWhenButtonClickedAndPreClickIntervalExceededThenTipIsNotYetShown() { + // 45s ago exceeds preClickInterval (30s DEBUG) but not postClickInterval (60s DEBUG) + defaults.set(Date().timeIntervalSince1970 - 45, forKey: "feedbackTip.lastShown") + defaults.set(true, forKey: "feedbackTip.buttonClicked") + + let controller = QuickFeedbackTipController(defaults: defaults) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + + autoreleasepool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true) + window.contentView?.addSubview(anchor) + + controller.scheduleIfNeeded(anchoredTo: anchor) + } + + let lastShown = defaults.double(forKey: "feedbackTip.lastShown") + XCTAssertLessThan(Date().timeIntervalSince1970 - lastShown, 50, + "lastShown should not have been updated — tip should not have been scheduled") + } + + func testWhenButtonClickedAndPostClickIntervalExceededThenTipIsScheduled() { + // 90s ago exceeds postClickInterval (60s DEBUG) + let oldTimestamp = Date().timeIntervalSince1970 - 90 + defaults.set(oldTimestamp, forKey: "feedbackTip.lastShown") + defaults.set(true, forKey: "feedbackTip.buttonClicked") + + let controller = QuickFeedbackTipController(defaults: defaults) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + + autoreleasepool { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true) + window.contentView?.addSubview(anchor) + + controller.scheduleIfNeeded(anchoredTo: anchor) + } + + let currentTimestamp = defaults.double(forKey: "feedbackTip.lastShown") + XCTAssertEqual(currentTimestamp, oldTimestamp, accuracy: 0.001, + "Timestamp should not change synchronously when scheduling is deferred") + } +}