diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 67adecd..c6f7a27 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -589,6 +589,43 @@ final class AppState: ObservableObject { courseTags = (try? db.fetchAllCourseTags(siteID: site.id)) ?? [:] } + /// Import Finder tags that users may have applied directly on course + /// folders in `~/Library/CloudStorage`. Tags already tracked in the + /// database are left untouched; only newly-discovered tags are added. + func importFinderTagsFromDisk() async { + guard let site = currentSite, let db = database else { return } + guard let rootURL = await fileProviderRootURL(for: site) else { return } + + for course in courses where course.isSyncEnabled { + let folderURL = rootURL.appendingPathComponent(course.effectiveFolderName, isDirectory: true) + guard let diskTags = Self.readFinderTags(at: folderURL), !diskTags.isEmpty else { continue } + + let existingTags = (try? db.fetchCourseTags(courseID: course.id, siteID: course.siteID)) ?? [] + let existingNames = Set(existingTags.map(\.name)) + let newTags = diskTags.filter { !existingNames.contains($0.name) } + guard !newTags.isEmpty else { continue } + + let merged = existingTags + newTags + updateCourseTags(for: course, tags: merged) + } + } + + /// Read Finder tags from a file/directory URL by parsing the + /// `com.apple.metadata:_kMDItemUserTags` resource value. + private static func readFinderTags(at url: URL) -> [FinderTag]? { + guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]), + let names = values.tagNames, !names.isEmpty else { return nil } + + return names.compactMap { raw -> FinderTag? in + // macOS stores tags as "Name\nColorIndex" or just "Name" + let parts = raw.split(separator: "\n", maxSplits: 1) + let name = String(parts[0]) + let colorIndex = parts.count > 1 ? Int(parts[1]) ?? 0 : 0 + let color = FinderTag.Color(rawValue: colorIndex) ?? .none + return FinderTag(name: name, color: color) + } + } + // MARK: - Course Customization func updateCustomFolderName(for course: MoodleCourse, name: String?) { @@ -834,11 +871,13 @@ final class AppState: ObservableObject { targetURL = rootURL } - // Use selectFile/activateFileViewerSelecting instead of open() — the - // sandbox blocks NSWorkspace.open() on File Provider URLs, but revealing - // in Finder works because it asks Finder to navigate rather than the app - // to open the path. - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: targetURL.path) + // NSWorkspace.shared.open() can be blocked by the sandbox for File + // Provider CloudStorage URLs after binary changes (e.g. Sparkle updates). + // Shell out to /usr/bin/open which is not subject to the app sandbox. + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = [targetURL.path] + try? process.run() } func resetProvider() async { @@ -967,6 +1006,8 @@ final class AppState: ObservableObject { try Task.checkCancellation() await self.loadCourses() try Task.checkCancellation() + await self.importFinderTagsFromDisk() + try Task.checkCancellation() if triggerLaunchSync && self.userDefaults.bool(forKey: Self.syncOnLaunchKey) { await self.syncAll() } diff --git a/Sources/App/Views/ContentView.swift b/Sources/App/Views/ContentView.swift index b99095d..8237f80 100644 --- a/Sources/App/Views/ContentView.swift +++ b/Sources/App/Views/ContentView.swift @@ -24,6 +24,7 @@ struct ContentView: View { ) ) .whatsNewSheet() + .supportPrompt() } } } diff --git a/Sources/App/Views/Courses/CoursesView.swift b/Sources/App/Views/Courses/CoursesView.swift index 298590e..0db9a41 100644 --- a/Sources/App/Views/Courses/CoursesView.swift +++ b/Sources/App/Views/Courses/CoursesView.swift @@ -10,24 +10,31 @@ struct CourseRow: View { let course: MoodleCourse let tags: [FinderTag] - var body: some View { - HStack(spacing: 8) { - Image(systemName: course.customIconName ?? "folder.fill") - .foregroundStyle(course.isSyncEnabled ? .secondary : .quaternary) - .imageScale(.large) + private var iconName: String { + course.customIconName ?? "folder.fill" + } + + private var primaryText: String { + if let custom = course.customFolderName, + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + return course.fullName + } + var body: some View { + Label { VStack(alignment: .leading, spacing: 2) { - if let custom = course.customFolderName, - !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(custom) - .lineLimit(2) + Text(primaryText) + .lineLimit(2) + + if course.customFolderName != nil && + !(course.customFolderName ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(course.fullName) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) } else { - Text(course.fullName) - .lineLimit(2) Text(course.shortName) .font(.subheadline) .foregroundStyle(.secondary) @@ -43,6 +50,9 @@ struct CourseRow: View { } } } + } icon: { + Image(systemName: iconName) + .foregroundStyle(course.isSyncEnabled ? .secondary : .quaternary) } .opacity(course.isSyncEnabled ? 1.0 : 0.5) .padding(.vertical, 1) diff --git a/Sources/App/Views/Workspace/SupportPrompt.swift b/Sources/App/Views/Workspace/SupportPrompt.swift new file mode 100644 index 0000000..35cb6e7 --- /dev/null +++ b/Sources/App/Views/Workspace/SupportPrompt.swift @@ -0,0 +1,47 @@ +// Copyright 2026 Alejandro Modroño Vara +// +// Licensed under the Apache License, Version 2.0. +// You may obtain a copy of the License in the LICENSE file at the root of this repository. + +import SwiftUI + +/// Occasional prompt asking users to support the project. +struct SupportPrompt: ViewModifier { + private static let launchCountKey = "supportPromptLaunchCount" + private static let minLaunches = 5 + private static let showProbability = 0.3 + + @AppStorage(SupportPrompt.launchCountKey) private var launchCount = 0 + @State private var showAlert = false + + func body(content: Content) -> some View { + content + .onAppear { + launchCount += 1 + guard launchCount >= Self.minLaunches, + Double.random(in: 0...1) < Self.showProbability else { return } + showAlert = true + } + .alert("Enjoying Findle?", isPresented: $showAlert) { + Button("Star on GitHub") { + if let url = URL(string: "https://github.com/alexmodrono/Findle") { + NSWorkspace.shared.open(url) + } + } + Button("Buy Me a Coffee") { + if let url = URL(string: "https://buymeacoffee.com/amodrono") { + NSWorkspace.shared.open(url) + } + } + Button("Maybe Later", role: .cancel) {} + } message: { + Text("Please consider leaving a star on GitHub or donating to support open-source projects like this.") + } + } +} + +extension View { + func supportPrompt() -> some View { + modifier(SupportPrompt()) + } +}