Ariver
2026-07-02 01bab5422712299b49f656e6b4c42a3bf314f519
src/Apptag/PreferencesView.swift
@@ -54,6 +54,7 @@
    private let settingsWindowWidth: CGFloat = 1000
    private let settingsWindowHeight: CGFloat = 640
    private let settingsContentWidth: CGFloat = 940
    private let languageContentWidth: CGFloat = 820
    private let generalContentWidth: CGFloat = 720
    private let generalLabelWidth: CGFloat = 190
    private let generalControlWidth: CGFloat = 500
@@ -93,10 +94,13 @@
    @State private var hotkeyStatusToastToken: UUID? = nil
    @State private var recordingHotkeyKind: LauncherHotkeyKind? = nil
    @State private var hotkeyRecorderMonitor: Any? = nil
    @State private var settingsEscapeMonitor: Any? = nil
    @State private var hotkeySettingsRefreshToken = UUID()
    @State private var selectedTab: SettingsTab = .general
    @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart
    @State private var settingsProPromptFeature: ProFeature? = nil
    @State private var settingsCustomContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
    @State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus()
    @State private var themePreviewNow = Date()
    init(initialTabRawValue: String? = nil) {
@@ -116,11 +120,13 @@
            apps = TagEditor.annotate(apps: apps)
            let colors = store.tags.mapValues { $0.color }
            let customColors = store.tags.compactMapValues { $0.customColor }
            let quotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
            DispatchQueue.main.async {
                allApps = apps
                tagColors = colors
                tagCustomColors = customColors
                categoryScheme = store.categoryScheme
                settingsCustomContainerQuotaOverviewStatus = quotaStatus
            }
        }
    }
@@ -230,6 +236,68 @@
        }
    }
    private func installSettingsEscapeMonitor() {
        guard settingsEscapeMonitor == nil else { return }
        settingsEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
            guard Int(event.keyCode) == kVK_Escape,
                  isSettingsOverlayEscapeEvent(event)
            else { return event }
            return handleSettingsEscapeKey() ? nil : event
        }
    }
    private func removeSettingsEscapeMonitor() {
        if let settingsEscapeMonitor {
            NSEvent.removeMonitor(settingsEscapeMonitor)
        }
        settingsEscapeMonitor = nil
    }
    private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool {
        guard let window = preferencesWindow,
              window.isVisible,
              let parent = window.parent as? OverlayPanel,
              parent.isVisible
        else { return false }
        if let eventWindow = event.window {
            return eventWindow == window || eventWindow == parent || eventWindow.parent == window
        }
        return NSApp.keyWindow == window || NSApp.keyWindow == parent || NSApp.mainWindow == window
    }
    private func handleSettingsEscapeKey() -> Bool {
        guard let window = preferencesWindow else { return false }
        guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false }
        if settingsProPromptFeature != nil {
            dismissSettingsProPrompt()
            return true
        }
        if showApplySystemSchemeConfirmation {
            withAnimation(.easeOut(duration: 0.14)) {
                showApplySystemSchemeConfirmation = false
            }
            return true
        }
        if showResetToUncategorizedConfirmation {
            withAnimation(.easeOut(duration: 0.14)) {
                showResetToUncategorizedConfirmation = false
            }
            return true
        }
        if recordingHotkeyKind != nil {
            stopHotkeyRecording(showCancelledToast: true)
            return true
        }
        window.performClose(nil)
        return true
    }
    private var preferencesWindow: NSWindow? {
        if let taggedWindow = NSApp.windows.first(where: {
            $0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible
@@ -242,14 +310,21 @@
            .first(where: isPreferencesWindowFallback(_:))
    }
    private var localizedPreferencesWindowTitle: String {
        tr("menu.preferences").replacingOccurrences(of: "…", with: "")
    }
    private func refreshPreferencesWindowTitle() {
        preferencesWindow?.title = localizedPreferencesWindowTitle
    }
    private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool {
        guard window.isVisible,
              !(window is NSPanel),
              !(window is OverlayPanel)
        else { return false }
        let preferencesTitle = tr("menu.preferences").replacingOccurrences(of: "…", with: "")
        return window.title == preferencesTitle
        return window.title == localizedPreferencesWindowTitle
    }
    private func showDataAlert(title: String, message: String) {
@@ -842,6 +917,38 @@
        }
    }
    private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
        if proEntitlement.isUnlocked {
            return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
        }
        return settingsCustomContainerQuotaOverviewStatus
    }
    private var customContainerQuotaAccessory: some View {
        let status = effectiveCustomContainerQuotaStatus
        let text = status.isUnlimited
            ? tr("pro.customContainers.quotaUnlimitedShort")
            : tr(
                "pro.customContainers.quotaShort",
                replacements: [
                    "%used%": "\(status.used)",
                    "%limit%": "\(status.limit)"
                ]
            )
        return Text(text)
            .font(.system(size: 12, weight: .medium))
            .foregroundStyle(.secondary)
            .lineLimit(1)
    }
    private func refreshSettingsCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
        settingsCustomContainerQuotaOverviewStatus = status
    }
    private func refreshSettingsCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
        settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
    }
    private var shouldShowSettingsFixedProHeader: Bool {
        true
    }
@@ -932,6 +1039,24 @@
    private var proTabComparisonFeatures: [ProTabComparisonFeature] {
        [
            ProTabComparisonFeature(
                title: tr("pro.feature.maintainableTagCount"),
                freeStatusKey: "settings.proStatus.maxSixTags",
                proLockedStatusKey: "settings.proStatus.unlimitedTags",
                proUnlockedStatusKey: "settings.proStatus.unlimitedTags",
                freeStyle: .neutral,
                proLockedStyle: .neutral,
                proUnlockedStyle: .neutral
            ),
            ProTabComparisonFeature(
                title: tr("pro.feature.containerAppOrderPersistence"),
                freeStatusKey: "settings.proStatus.previewOnly",
                proLockedStatusKey: "settings.proStatus.supported",
                proUnlockedStatusKey: "settings.proStatus.supported",
                freeStyle: .neutral,
                proLockedStyle: .neutral,
                proUnlockedStyle: .neutral
            ),
            ProTabComparisonFeature(
                title: tr("pro.feature.customTagColors"),
                freeStatusKey: "settings.proStatus.unavailableFeature",
@@ -1169,11 +1294,22 @@
        return effectiveDisplayMode == mode ? .checkmark : .circle
    }
    private func presentSettingsProPrompt(for feature: ProFeature) {
    private func presentSettingsProPrompt(
        for feature: ProFeature,
        customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
    ) {
        proEntitlement.clearTransientOperationState()
        settingsCustomContainerQuotaStatus = customContainerQuotaStatus
        withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
            settingsProPromptFeature = feature
        }
    }
    private func presentSettingsCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
        presentSettingsProPrompt(
            for: .customContainerQuota,
            customContainerQuotaStatus: status
        )
    }
    private var themePreviewCountdownText: String? {
@@ -1208,6 +1344,38 @@
        withAnimation(.easeOut(duration: 0.18)) {
            settingsProPromptFeature = nil
        }
        settingsCustomContainerQuotaStatus = nil
    }
    private func settingsProPromptMessage(for feature: ProFeature) -> String {
        if feature == .customContainerQuota,
           let status = settingsCustomContainerQuotaStatus,
           !status.isUnlimited {
            return tr(
                "pro.customContainers.quotaPrompt",
                replacements: [
                    "%used%": "\(status.used)",
                    "%limit%": "\(status.limit)",
                    "%remaining%": "\(status.remaining)"
                ]
            )
        }
        return tr(feature.promptMessageKey)
    }
    private func settingsProPromptSecondaryTitle(for feature: ProFeature) -> String {
        feature == .customContainerQuota
            ? tr("pro.customContainers.manageExisting")
            : tr("pro.card.restore")
    }
    private func handleSettingsProPromptSecondaryAction(for feature: ProFeature) {
        if feature == .customContainerQuota {
            dismissSettingsProPrompt()
            selectedTab = .tags
            return
        }
        proEntitlement.restorePurchases()
    }
    private func handleProAccessStateChange(_ newState: ProAccessState) {
@@ -1487,7 +1655,7 @@
                    switch selectedTab {
                    case .language:
                // Tab 1: Language
                centeredSettingsScrollContent(width: settingsContentWidth) {
                centeredSettingsScrollContent(width: languageContentWidth) {
                        LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) {
                            Button {
                                selectedLanguage = L10n.automaticCode
@@ -1680,10 +1848,19 @@
                    excludedTagNames: ["Mac自带", defaultGroupName],
                    isCustomColorUnlocked: proEntitlement.isUnlocked,
                    onLockedCustomColor: { presentSettingsProPrompt(for: .customTagColors) },
                    onRefresh: { scanApps() }
                    onCustomContainerQuotaExceeded: presentSettingsCustomContainerQuotaPrompt,
                    onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus,
                    topLeadingAccessory: AnyView(customContainerQuotaAccessory),
                    onRefresh: {
                        refreshSettingsCustomContainerQuotaStatus()
                        scanApps()
                    }
                )
            }
            .onAppear { scanApps() }
            .onAppear {
                refreshSettingsCustomContainerQuotaStatus()
                scanApps()
            }
                    case .data:
            // Tab 5: Data
@@ -1908,6 +2085,7 @@
                } else {
                    syncSelectedLanguage()
                }
                refreshPreferencesWindowTitle()
                scanApps()
                refreshDataState()
                showLanguageRefresh()
@@ -1977,15 +2155,15 @@
                    ProUpgradePromptView(
                        title: tr(feature.titleKey),
                        message: tr(feature.promptMessageKey),
                        message: settingsProPromptMessage(for: feature),
                        benefitText: tr(feature.benefitKey),
                        operationText: proEntitlement.operationState.statusMessageKey.map(tr),
                        unlockTitle: unlockButtonTitle(),
                        restoreTitle: tr("pro.card.restore"),
                        restoreTitle: settingsProPromptSecondaryTitle(for: feature),
                        isBusy: proEntitlement.operationState.isBusy,
                        onClose: dismissSettingsProPrompt,
                        onUnlock: { proEntitlement.purchasePro() },
                        onRestore: { proEntitlement.restorePurchases() }
                        onRestore: { handleSettingsProPromptSecondaryAction(for: feature) }
                    )
                }
                .zIndex(4)
@@ -2017,10 +2195,13 @@
        .frame(width: settingsWindowWidth, height: settingsWindowHeight)
        .onAppear {
            syncSelectedLanguage()
            refreshPreferencesWindowTitle()
            installSettingsEscapeMonitor()
            showPendingHotkeyWarningIfNeeded()
            refreshThemePreviewCountdown()
        }
        .onDisappear {
            removeSettingsEscapeMonitor()
            stopHotkeyRecording()
        }
        .onReceive(themePreviewCountdownTimer) { now in