Files
ventry-upload-watcher/Ventry Upload Watcher/ContentView.swift
2025-01-19 04:43:22 +01:00

1153 lines
41 KiB
Swift

//
// ContentView.swift
// Ventry Upload Watcher
//
// Created by Jan-Marlon Leibl on 18.01.25.
//
import SwiftUI
import AppKit
import UserNotifications
import ServiceManagement
import CoreServices
extension NSWindow {
open override func awakeFromNib() {
super.awakeFromNib()
self.styleMask.remove(.resizable)
self.styleMask.remove(.fullScreen)
}
}
extension Color {
static let accentPurple = Color(hex: 0x8331FD)
}
extension Color {
init(hex: UInt) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xff) / 255,
green: Double((hex >> 8) & 0xff) / 255,
blue: Double(hex & 0xff) / 255,
opacity: 1
)
}
}
// MARK: - Upload Manager
class UploadManager {
private static let endpoint = URL(string: "https://ventry.host/api/upload")!
static func uploadFile(_ fileURL: URL, apiKey: String, completion: @escaping (Result<String, Error>) -> Void) {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue(apiKey, forHTTPHeaderField: "Authorization")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
URLSession.shared.uploadTask(with: request, from: createMultipartFormData(fileURL: fileURL, boundary: boundary)) { data, response, error in
if let error = error { return completion(.failure(error)) }
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let data = data,
let responseString = String(data: data, encoding: .utf8) else {
return completion(.failure(NSError(domain: "", code: (response as? HTTPURLResponse)?.statusCode ?? -1)))
}
completion(.success(responseString))
}.resume()
}
private static func createMultipartFormData(fileURL: URL, boundary: String) -> Data {
var data = Data()
let filename = fileURL.lastPathComponent.replacingOccurrences(of: " ", with: "%20")
data.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(fileURL.mimeType())\r\n\r\n".data(using: .utf8)!)
if let fileData = try? Data(contentsOf: fileURL) {
data.append(fileData)
data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
}
return data
}
}
extension URL {
func mimeType() -> String {
let mimeTypes = ["jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"gif": "image/gif", "webp": "image/webp", "mp4": "video/mp4",
"mov": "video/quicktime"]
return mimeTypes[pathExtension.lowercased()] ?? "application/octet-stream"
}
}
// MARK: - Folder Monitor
class FolderMonitor: ObservableObject {
@Published private(set) var isWatching = false
private var timer: Timer?
private var currentPaths: [String] = []
private var knownFiles = Set<String>()
var onFileDetected: ((URL) -> Void)?
deinit { stopWatching() }
func startWatching(paths: [String]) {
stopWatching()
currentPaths = paths
for path in paths {
let url = URL(fileURLWithPath: path)
guard url.startAccessingSecurityScopedResource() else {
NotificationManager.send(title: "Permission Required", message: "Please grant folder access")
continue
}
saveBookmark(for: url)
}
updateKnownFiles()
startMonitoring()
}
private func startMonitoring() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.checkForNewFiles()
}
isWatching = true
}
private func checkForNewFiles() {
for path in currentPaths {
do {
let contents = try FileManager.default.contentsOfDirectory(atPath: path)
let newFiles = Set(contents).subtracting(knownFiles)
for file in newFiles {
let fileURL = URL(fileURLWithPath: path).appendingPathComponent(file)
if fileURL.lastPathModified.timeIntervalSinceNow > -2 {
onFileDetected?(fileURL)
}
}
knownFiles = knownFiles.union(newFiles)
} catch {
print("Error checking files in \(path): \(error)")
}
}
}
private func updateKnownFiles() {
var allFiles = Set<String>()
for path in currentPaths {
guard let contents = try? FileManager.default.contentsOfDirectory(atPath: path) else { continue }
allFiles = allFiles.union(Set(contents.map { path + "/" + $0 }))
}
knownFiles = allFiles
}
private func saveBookmark(for url: URL) {
guard let bookmarkData = try? url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
) else { return }
UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)")
}
func stopWatching() {
timer?.invalidate()
timer = nil
isWatching = false
currentPaths.removeAll()
knownFiles.removeAll()
}
func addPath(_ path: String) {
if !currentPaths.contains(path) {
currentPaths.append(path)
let url = URL(fileURLWithPath: path)
guard url.startAccessingSecurityScopedResource() else {
NotificationManager.send(title: "Permission Required", message: "Please grant folder access")
return
}
saveBookmark(for: url)
updateKnownFiles()
}
}
}
// MARK: - Notification Manager
class NotificationManager {
static func send(title: String, message: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = message
content.sound = .default
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
if granted {
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString,
content: content, trigger: nil))
}
}
}
}
// MARK: - Haptics
class HapticManager {
static func play(_ type: NSHapticFeedbackManager.FeedbackPattern) {
NSHapticFeedbackManager.defaultPerformer.perform(type, performanceTime: .default)
}
static func success() { play(.levelChange) }
static func error() { play(.generic) }
static func tap() { play(.alignment) }
}
// MARK: - Views
struct CardView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding()
.background(Color(.windowBackgroundColor))
.cornerRadius(12)
}
}
struct StatusBadge: View {
let status: String
let color: Color
var body: some View {
Text(status)
.font(.caption.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.2))
.foregroundColor(color)
.cornerRadius(6)
}
}
class LaunchAtLoginHelper {
static func setLaunchAtLogin(_ enabled: Bool) {
let service = SMAppService.mainApp
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
print("Successfully \(enabled ? "enabled" : "disabled") launch at login")
} catch {
print("Failed to \(enabled ? "enable" : "disable") launch at login: \(error)")
}
}
}
struct SettingsView: View {
@Binding var isPresented: Bool
@Binding var apiKey: String
@Environment(\.colorScheme) var colorScheme
@FocusState private var isAPIKeyFocused: Bool
@AppStorage("launchAtLogin") private var launchAtLogin = false
var body: some View {
VStack(spacing: Design.padding) {
HStack {
Text("Settings")
.font(.system(size: 24, weight: .bold))
Spacer()
Button(action: {
withAnimation(Animation.spring) {
isPresented = false
}
}) {
Circle()
.fill(Color(.windowBackgroundColor).opacity(0.8))
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "xmark")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
)
}
.buttonStyle(.plain)
}
VStack(alignment: .leading, spacing: Design.spacing) {
Text("API Key")
.font(.system(size: 15, weight: .semibold))
SecureField("Enter your ventry.host API key", text: $apiKey)
.textFieldStyle(.plain)
.padding(12)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
.fill(Color(.textBackgroundColor))
)
.focused($isAPIKeyFocused)
Text("Get your API key from ventry.host")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.padding(.bottom, 8)
VStack(alignment: .leading, spacing: Design.spacing) {
Text("Startup Options")
.font(.system(size: 15, weight: .semibold))
.padding(.bottom, 4)
Toggle(isOn: $launchAtLogin) {
VStack(alignment: .leading, spacing: 2) {
Text("Launch at login")
.font(.system(size: 14))
Text("Automatically start the app when you log in")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
.onChange(of: launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setLaunchAtLogin(newValue)
}
}
Spacer()
Button(action: {
HapticManager.success()
withAnimation(Animation.spring) {
isPresented = false
}
}) {
Text("Save")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
.fill(Color.accentPurple)
)
}
.buttonStyle(.plain)
.keyboardShortcut(.return)
}
.padding(24)
.frame(width: 400, height: 360)
.background(colorScheme == .dark ? Color(.windowBackgroundColor) : .white)
.onAppear { isAPIKeyFocused = true }
}
}
@propertyWrapper
struct UserDefaultsArray<T: Codable> {
private let key: String
init(key: String) {
self.key = key
}
var wrappedValue: [T] {
get {
guard let data = UserDefaults.standard.data(forKey: key),
let array = try? JSONDecoder().decode([T].self, from: data) else {
return []
}
return array
}
set {
guard let data = try? JSONEncoder().encode(newValue) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}
}
struct HeaderView: View {
@Binding var showingSettings: Bool
var body: some View {
HStack(spacing: Design.padding) {
VStack(alignment: .leading, spacing: 2) {
Text("ventry.host")
.font(.system(size: 24, weight: .bold))
Text("Tool for uploading files to ventry.host")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
HapticManager.tap()
withAnimation(Animation.spring) {
showingSettings.toggle()
}
}) {
Image(systemName: "gear")
.font(.system(size: Design.iconSize))
.foregroundColor(.secondary)
.frame(width: 32, height: 32)
.background(Color(.controlBackgroundColor))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
}
struct StatusCardView: View {
let folderMonitor: FolderMonitor
let uploadStatus: String
let lastUpload: String
var body: some View {
VStack(spacing: Design.padding) {
HStack {
ZStack {
Circle()
.fill(folderMonitor.isWatching ? Color.green.opacity(0.15) : Color.red.opacity(0.15))
.frame(width: 24, height: 24)
Circle()
.fill(folderMonitor.isWatching ? Color.green : Color.red)
.frame(width: 8, height: 8)
.blur(radius: folderMonitor.isWatching ? 2 : 0)
}
Text(folderMonitor.isWatching ? "Watching" : "Inactive")
.font(.system(size: Design.iconSize, weight: .medium))
.foregroundColor(folderMonitor.isWatching ? .green : .red)
Spacer()
}
VStack(spacing: Design.spacing) {
statusRow(
icon: "arrow.up.circle",
title: "Status",
value: uploadStatus
)
Divider()
.background(Color.primary.opacity(Design.opacity))
statusRow(
icon: "clock",
title: "Last Upload",
value: lastUpload
)
}
}
.padding(Design.padding)
.background(Color(.controlBackgroundColor))
.cornerRadius(Design.cornerRadius)
.animation(Animation.spring, value: folderMonitor.isWatching)
}
private func statusRow(icon: String, title: String, value: String) -> some View {
HStack(spacing: Design.spacing) {
Image(systemName: icon)
.font(.system(size: Design.iconSize))
.foregroundColor(.secondary)
.frame(width: 16)
Text(title)
.font(.system(size: Design.iconSize))
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.system(size: Design.iconSize, weight: .medium))
.multilineTextAlignment(.trailing)
}
}
}
struct WatchedFoldersView: View {
@Binding var watchedFolders: [String]
let folderMonitor: FolderMonitor
let onAddFolder: () -> Void
let onStopWatching: () -> Void
var body: some View {
VStack(spacing: Design.spacing) {
List {
ForEach(watchedFolders, id: \.self) { path in
folderCard(path)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
withAnimation(Animation.spring) {
watchedFolders.removeAll { $0 == path }
if watchedFolders.isEmpty {
folderMonitor.stopWatching()
} else {
folderMonitor.startWatching(paths: watchedFolders)
}
}
HapticManager.tap()
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.frame(height: CGFloat(watchedFolders.count * 60))
.padding(.horizontal, -8)
VStack(spacing: Design.spacing) {
Button(action: {
HapticManager.tap()
onAddFolder()
}) {
HStack(spacing: Design.spacing) {
Image(systemName: "folder.badge.plus")
.font(.system(size: Design.iconSize))
Text("Add Another Folder")
.font(.system(size: Design.iconSize))
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius)
.fill(Color.accentPurple.opacity(Design.opacity))
)
.foregroundColor(.accentPurple)
}
.buttonStyle(.plain)
Button(action: {
HapticManager.tap()
onStopWatching()
}) {
HStack(spacing: Design.spacing) {
Image(systemName: "stop.circle.fill")
.font(.system(size: Design.iconSize))
Text("Stop Watching")
.font(.system(size: Design.iconSize))
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius)
.fill(Color.red.opacity(Design.opacity))
)
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
}
private func folderCard(_ path: String) -> some View {
HStack(spacing: Design.spacing) {
Image(systemName: "folder.fill")
.font(.system(size: Design.iconSize))
.foregroundColor(.accentPurple)
Text(path)
.font(.system(size: Design.iconSize))
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 12)
.padding(.horizontal, Design.padding)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
.fill(Color.accentPurple.opacity(Design.opacity))
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
struct LeftSideView: View {
let folderMonitor: FolderMonitor
let uploadStatus: String
let lastUpload: String
@Binding var showingSettings: Bool
@Binding var watchedFolders: [String]
let apiKey: String
let onToggleWatching: () -> Void
let onAddFolder: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 16) {
HeaderView(showingSettings: $showingSettings)
StatusCardView(folderMonitor: folderMonitor, uploadStatus: uploadStatus, lastUpload: lastUpload)
if !watchedFolders.isEmpty {
WatchedFoldersView(
watchedFolders: $watchedFolders,
folderMonitor: folderMonitor,
onAddFolder: onAddFolder,
onStopWatching: onToggleWatching
)
}
if watchedFolders.isEmpty {
ActionButtonView(folderMonitor: folderMonitor, apiKey: apiKey, onToggleWatching: onToggleWatching)
}
Spacer(minLength: 0)
}
.padding(24)
}
.frame(width: 360)
.background(Color(.windowBackgroundColor))
}
}
struct RightSideView: View {
let isUploading: Bool
@Binding var uploadHistory: [(date: Date, url: String, filePath: String)]
@Binding var copiedURL: String?
let folderMonitor: FolderMonitor
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Recent Uploads")
.font(.system(size: 20, weight: .bold))
if isUploading {
UploadingCardView()
}
UploadHistoryListView(
uploadHistory: $uploadHistory,
copiedURL: $copiedURL,
folderMonitor: folderMonitor
)
}
.padding(24)
.frame(maxWidth: .infinity)
.background(Color(.textBackgroundColor))
}
}
struct ActionButtonView: View {
let folderMonitor: FolderMonitor
let apiKey: String
let onToggleWatching: () -> Void
var body: some View {
Button(action: {
HapticManager.tap()
onToggleWatching()
}) {
HStack(spacing: 8) {
Image(systemName: folderMonitor.isWatching ? "stop.circle.fill" : "folder.badge.plus")
Text(folderMonitor.isWatching ? "Stop Watching" : "Add Folders...")
.font(.system(size: 15, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(folderMonitor.isWatching ? Color.red : Color(.controlAccentColor))
.cornerRadius(12)
.foregroundColor(.white)
}
.buttonStyle(.plain)
.disabled(apiKey.isEmpty)
.opacity(apiKey.isEmpty ? 0.5 : 1)
.help(folderMonitor.isWatching ? "Stop watching all folders" : "Select multiple folders to watch")
}
}
struct UploadingCardView: View {
var body: some View {
HStack(spacing: 12) {
ProgressView()
.scaleEffect(0.8)
Text("Uploading file...")
.font(.system(size: 14, weight: .medium))
Spacer()
}
.padding(16)
.background(
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.accentPurple.opacity(0.1))
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(
LinearGradient(
colors: [Color.accentPurple.opacity(0.1), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
private enum Animation {
static let spring = SwiftUI.Animation.spring(response: 0.3, dampingFraction: 0.7)
static let copyDelay: TimeInterval = 1.5
}
private enum Design {
static let cornerRadius: CGFloat = 12
static let opacity = 0.1
static let iconSize: CGFloat = 14
static let spacing: CGFloat = 8
static let padding: CGFloat = 16
}
struct UploadHistoryListView: View {
@Binding var uploadHistory: [(date: Date, url: String, filePath: String)]
@Binding var copiedURL: String?
let folderMonitor: FolderMonitor
@Namespace private var scrollSpace
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(uploadHistory, id: \.date) { upload in
lastUploadCard(upload.url, date: upload.date, filePath: upload.filePath)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation(Animation.spring) {
uploadHistory.removeAll { $0.url == upload.url }
}
HapticManager.tap()
} label: {
Label("Delete", systemImage: "trash")
}
Button {
if let url = URL(string: upload.url) {
NSWorkspace.shared.open(url)
HapticManager.tap()
}
} label: {
Label("Open", systemImage: "safari")
}
.tint(.accentPurple)
}
.id(upload.date)
}
if uploadHistory.isEmpty && !folderMonitor.isWatching {
VStack(spacing: Design.spacing) {
Image(systemName: "folder.badge.plus")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("Select a folder to start watching")
.font(.system(size: Design.iconSize))
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 160)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.onChange(of: uploadHistory.map(\.url)) { oldValue, newValue in
if !newValue.isEmpty {
withAnimation(Animation.spring) {
proxy.scrollTo(uploadHistory[0].date, anchor: .top)
}
}
}
}
}
private func lastUploadCard(_ url: String, date: Date = Date(), filePath: String? = nil) -> some View {
Button(action: {
copyToClipboard(url)
withAnimation(Animation.spring) {
copiedURL = url
}
HapticManager.success()
DispatchQueue.main.asyncAfter(deadline: .now() + Animation.copyDelay) {
withAnimation(Animation.spring) {
if copiedURL == url {
copiedURL = nil
}
}
}
}) {
VStack(alignment: .leading, spacing: Design.spacing) {
if let filePath = filePath,
let nsImage = NSImage(contentsOf: URL(fileURLWithPath: filePath)) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 120)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: Design.cornerRadius))
}
VStack(alignment: .leading, spacing: Design.spacing) {
Text(date.formatted(date: .numeric, time: .shortened))
.font(.system(size: 12))
.foregroundColor(.secondary)
HStack(spacing: Design.spacing) {
Image(systemName: "link")
.font(.system(size: Design.iconSize))
.foregroundColor(copiedURL == url ? .green : .accentPurple)
Text(url)
.font(.system(size: Design.iconSize))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
HStack(spacing: 4) {
if copiedURL == url {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: Design.iconSize))
.transition(.scale.combined(with: .opacity))
Text("Copied!")
} else {
Text("Copy")
}
}
.font(.system(size: 12, weight: .medium))
.foregroundColor(copiedURL == url ? .green : .accentPurple)
.animation(Animation.spring, value: copiedURL)
}
}
}
.padding(Design.padding)
.background(
RoundedRectangle(cornerRadius: Design.cornerRadius, style: .continuous)
.fill(copiedURL == url ? Color.green.opacity(Design.opacity) : Color.accentPurple.opacity(Design.opacity))
)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.animation(Animation.spring, value: copiedURL)
}
private func copyToClipboard(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}
class WindowManager {
static let shared = WindowManager()
private var window: NSWindow?
func setWindow(_ window: NSWindow?) {
self.window = window
}
func hideWindow() {
window?.orderOut(nil)
}
func showWindow() {
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
struct ContentView: View {
@StateObject private var folderMonitor = FolderMonitor()
@State private var uploadStatus = "No active uploads"
@State private var lastUpload = "Never"
@State private var showingSettings = false
@AppStorage("apiKey") private var apiKey = ""
@State private var watchedFolders: [String] = UserDefaults.standard.stringArray(forKey: "watchedFolders") ?? []
@State private var errorMessage: String?
@State private var showError = false
@State private var lastUploadURL: String?
@State private var isUploading = false
@State private var showCopiedBadge = false
@Environment(\.colorScheme) var colorScheme
@State private var uploadHistory: [(date: Date, url: String, filePath: String)] = []
@State private var copiedURL: String? = nil
var body: some View {
MainLayout {
HStack(spacing: 0) {
LeftSideView(
folderMonitor: folderMonitor,
uploadStatus: uploadStatus,
lastUpload: lastUpload,
showingSettings: $showingSettings,
watchedFolders: $watchedFolders,
apiKey: apiKey,
onToggleWatching: toggleWatching,
onAddFolder: selectFolder
)
RightSideView(
isUploading: isUploading,
uploadHistory: $uploadHistory,
copiedURL: $copiedURL,
folderMonitor: folderMonitor
)
}
}
.frame(width: 800, height: 400)
.background(Color(.windowBackgroundColor))
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage ?? "Unknown error")
}
.sheet(isPresented: $showingSettings) {
SettingsView(isPresented: $showingSettings, apiKey: $apiKey)
}
.onAppear {
setupFolderMonitor()
restoreWatching()
}
.onChange(of: watchedFolders) { oldValue, newValue in
UserDefaults.standard.set(newValue, forKey: "watchedFolders")
if !folderMonitor.isWatching && !newValue.isEmpty {
folderMonitor.startWatching(paths: newValue)
}
}
}
private func toggleWatching() {
if folderMonitor.isWatching {
folderMonitor.stopWatching()
uploadStatus = "Watching stopped"
watchedFolders.removeAll()
} else {
selectFolder()
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.message = "Select folders to watch for screenshots"
panel.prompt = "Watch Folders"
panel.begin { response in
guard response == .OK else { return }
DispatchQueue.main.async {
for url in panel.urls {
guard url.startAccessingSecurityScopedResource() else {
NotificationManager.send(title: "Permission Required", message: "Please grant folder access for \(url.path)")
continue
}
if !watchedFolders.contains(url.path) {
watchedFolders.append(url.path)
if let bookmarkData = try? url.bookmarkData(options: .withSecurityScope) {
UserDefaults.standard.set(bookmarkData, forKey: "FolderBookmark-\(url.path)")
}
}
}
if !watchedFolders.isEmpty {
if !folderMonitor.isWatching {
uploadStatus = "Starting to watch folders..."
folderMonitor.startWatching(paths: watchedFolders)
} else {
for url in panel.urls {
folderMonitor.addPath(url.path)
}
}
uploadStatus = "Watching for new files..."
NotificationManager.send(title: "Folders Added", message: "Now watching \(panel.urls.count) new folder(s)")
}
}
}
}
private func setupFolderMonitor() {
folderMonitor.onFileDetected = handleNewFile
}
private func handleNewFile(_ fileURL: URL) {
uploadStatus = "Uploading \(fileURL.lastPathComponent)..."
NotificationManager.send(title: "Uploading File", message: "Starting upload of \(fileURL.lastPathComponent)")
withAnimation { isUploading = true }
UploadManager.uploadFile(fileURL, apiKey: apiKey) { result in
DispatchQueue.main.async {
withAnimation { isUploading = false }
handleUploadResult(result)
}
}
}
private func handleUploadResult(_ result: Result<String, Error>) {
switch result {
case .success(let response): handleSuccessfulUpload(response)
case .failure(let error): handleFailedUpload(error)
}
}
private func handleSuccessfulUpload(_ response: String) {
do {
if let jsonData = response.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let url = json["url"] as? String,
let currentFile = lastUploadedFile?.path {
withAnimation(.spring()) {
uploadStatus = "Upload successful"
lastUpload = Date().formatted(date: .numeric, time: .shortened)
lastUploadURL = url
uploadHistory.insert((date: Date(), url: url, filePath: currentFile), at: 0)
if uploadHistory.count > 50 {
uploadHistory.removeLast()
}
}
copyToClipboard(url)
HapticManager.success()
NotificationManager.send(title: "Upload Successful", message: "URL copied to clipboard")
} else {
throw NSError(domain: "", code: -1)
}
} catch {
handleFailedUpload(error)
}
}
private func handleFailedUpload(_ error: Error) {
withAnimation {
errorMessage = error.localizedDescription
showError = true
uploadStatus = "Upload failed"
}
HapticManager.error()
NotificationManager.send(title: "Upload Failed", message: error.localizedDescription)
}
private var lastUploadedFile: URL? {
guard let path = watchedFolders.first else { return nil }
let fm = FileManager.default
let contents = try? fm.contentsOfDirectory(atPath: path)
return contents?.compactMap { URL(fileURLWithPath: path).appendingPathComponent($0) }
.sorted { $0.lastPathModified > $1.lastPathModified }
.first
}
private func copyToClipboard(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
uploadStatus = "URL copied to clipboard"
}
private func restoreWatching() {
guard !apiKey.isEmpty else { return }
var validPaths: [String] = []
for path in watchedFolders {
guard FileManager.default.fileExists(atPath: path),
let bookmarkData = UserDefaults.standard.data(forKey: "FolderBookmark-\(path)") else { continue }
var isStale = false
do {
let url = try URL(resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale)
guard url.startAccessingSecurityScopedResource() else {
print("Failed to access security scoped resource for path: \(path)")
continue
}
validPaths.append(url.path)
} catch {
print("Failed to resolve bookmark for path: \(path), error: \(error)")
}
}
if !validPaths.isEmpty {
DispatchQueue.main.async {
watchedFolders = validPaths
folderMonitor.startWatching(paths: validPaths)
uploadStatus = "Watching for new files..."
NotificationManager.send(title: "Ventry Upload Watcher", message: "Resumed watching \(validPaths.count) folder(s)")
}
}
}
}
struct MainLayout<Content: View>: View {
let content: Content
@Environment(\.colorScheme) var colorScheme
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
Color(colorScheme == .dark ? .windowBackgroundColor : .white)
.ignoresSafeArea()
content
}
}
}
#Preview {
ContentView()
}
extension URL {
var lastPathModified: Date {
(try? resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date.distantPast
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusItem()
if let window = NSApplication.shared.windows.first {
WindowManager.shared.setWindow(window)
}
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
let icon = NSImage(named: "ventry-logo-white")
icon?.isTemplate = true
icon?.size = NSSize(width: 18, height: 18)
button.image = icon
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Show Window", action: #selector(showWindow), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu
}
}
@objc private func showWindow() {
WindowManager.shared.showWindow()
}
}