diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7b8f456 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +ko_fi: gingerbeardman diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..17b2e8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +labels: bug +assignees: gingerbeardman + +--- + + + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Versions (please complete the following information):** + - Stapler version [e.g. 1.2.3] + - macOS version [e.g. 14.6.1] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..941524b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +labels: enhancement +assignees: gingerbeardman + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index b1dec0f..48c31e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate -Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate -Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate +Stapler.xcodeproj/xcuserdata/matt.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/README.md b/README.md index e8d27f0..29d382c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # Stapler -My take on classic Macintosh app [Stapler](https://macintoshgarden.org/apps/stapler-11) (Chris Patterson, Patterson Software Works, 1992). +My take on the classic Macintosh app [Stapler](https://macintoshgarden.org/apps/stapler-11) (Chris Patterson, Patterson Software Works, 1992). With a little bit of the early Mac OS X app [LaunchList](http://hasseg.org/launchList/) (Ali Rantakari, hasseg.org, 2009). +More info in [the accompanying blog post](https://blog.gingerbeardman.com/2024/08/10/stapler-i-remade-a-32-year-old-classic-macintosh-app/). + +## Download + +[https://github.com/gingerbeardman/stapler/releases/latest](https://github.com/gingerbeardman/stapler/releases/latest) + +- supported: macOS 12 and newer + ## What is it? The idea is you set up a *Stapler Document* per project containing related apps, files, folders, etc. @@ -12,16 +20,14 @@ Then you can open them all at once by launching the single document. Each document contains a list of aliases that can be managed, inspected, launched using the app. +Task-based computing. + screenshot ## Use cases -- Work: open Nova editor, run current game, pixel art editor, bitmap font app, Taskpaper todo list -- Play: Music app, Hacker News app, Twitter app -- Movie: run Caffeine to keep your computer on, shortcut to Sleep Displays - -## Download - -[https://github.com/gingerbeardman/stapler/releases/latest](https://github.com/gingerbeardman/stapler/releases/latest) +- Work: text editor, run current game, pixel art editor, bitmap font app, todo list +- Play: Music app, Hacker News app, Twitter app, script to position windows +- Movie: run Caffeine to keep your computer on, shortcut to put displays to sleep ---- @@ -51,7 +57,7 @@ All standard macOS Document-Based App conventions are supported through the File 2. All items in the list will be launched automatically 3. `Stapler.app` will close (if it was not already open) -*Tip*: hold the Cmd key as the *Stapler Document* is being launching to open it in edit mode. +*Tip*: hold the Cmd key whilst launching a *Stapler Document* to open it in edit mode. ### Launching specific items @@ -68,10 +74,12 @@ All standard macOS Document-Based App conventions are supported through the File - use `File` > `Open Recent` 3. Use the `Items` menu +*Tip*: hold the Cmd key whilst launching a *Stapler Document* to open it in edit mode. + ### Keyboard controls -|Key |Function| -|--|----| +|Press |Function| +|:--|:----| |Cmd + Return|Add… (open file selector)| |Backspace|Remove| |Space|Quick Look| @@ -80,8 +88,30 @@ All standard macOS Document-Based App conventions are supported through the File ### Permissions -- All files you select or drop are recorded only as system bookmarks -- The only files that are written are through the file save selector -- Read-only permission may be prompted for some folders +- All files you select or drop are recorded only as macOS filesystem bookmarks +- The only files that are written to are Stapler Documents - Network permission is required to Quick Look .webloc files +- The app should prompt for file access permission +- You may want to grant additional file access permissions at: + - `System Settings > Privacy & Security > Full Disk Access` +- Since 1.2.1 the app is Notarized so it can be run with fewer nags + +--- + +## Bonus tip + +System Preferences > Desktop & Dock > Windows > Close windows when quitting an application = OFF + +Then leave the windows of an app open as you quit it. When you next launch the app its windows will restore to their previous size and position. If you close the windows first, then the app will restore to having no windows open. + +---- + +## Get involved + +- Please use Discussions for any questions +- Wiki contains info for power users +- Bug reports and PRs are very welcome! + +## Licence +[MIT](/LICENSE) diff --git a/Stapler.xcodeproj/project.pbxproj b/Stapler.xcodeproj/project.pbxproj index a10dad0..5076843 100644 --- a/Stapler.xcodeproj/project.pbxproj +++ b/Stapler.xcodeproj/project.pbxproj @@ -53,9 +53,9 @@ isa = PBXGroup; children = ( C995EA3A2C6671FA00AB5C91 /* StaplerApp.swift */, - C995EA402C6671FB00AB5C91 /* Assets.xcassets */, C995EA452C6671FC00AB5C91 /* Info.plist */, C995EA462C6671FC00AB5C91 /* Stapler.entitlements */, + C995EA402C6671FB00AB5C91 /* Assets.xcassets */, C9434CC42C67E4F60054A3F0 /* Icon Artwork.png */, C995EA422C6671FC00AB5C91 /* Preview Content */, ); @@ -200,7 +200,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -257,7 +257,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -273,7 +273,7 @@ CODE_SIGN_ENTITLEMENTS = Stapler/Stapler.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 240810; + CURRENT_PROJECT_VERSION = 240823; DEVELOPMENT_ASSET_PATHS = "\"Stapler/Preview Content\""; DEVELOPMENT_TEAM = Q3Z639YB49; ENABLE_HARDENED_RUNTIME = YES; @@ -281,15 +281,15 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Stapler/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - INFOPLIST_KEY_NSDesktopFolderUsageDescription = "This app needs access to your Desktop folder to open and manage files."; - INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs access to your Documents folder to open and manage files."; - INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "This app needs access to your Downloads folder to open and manage files."; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Matt Sephton.\nAll rights reserved."; + INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Matt Sephton"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.gingerbeardman.Stapler; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -305,7 +305,7 @@ CODE_SIGN_ENTITLEMENTS = Stapler/Stapler.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 240810; + CURRENT_PROJECT_VERSION = 240823; DEVELOPMENT_ASSET_PATHS = "\"Stapler/Preview Content\""; DEVELOPMENT_TEAM = Q3Z639YB49; ENABLE_HARDENED_RUNTIME = YES; @@ -313,15 +313,15 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Stapler/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - INFOPLIST_KEY_NSDesktopFolderUsageDescription = "This app needs access to your Desktop folder to open and manage files."; - INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs access to your Documents folder to open and manage files."; - INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "This app needs access to your Downloads folder to open and manage files."; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Matt Sephton.\nAll rights reserved."; + INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Stapler needs access to your files to read aliases and create and manage its own files."; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Matt Sephton"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.gingerbeardman.Stapler; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate b/Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 56cfd89..0000000 Binary files a/Stapler.xcodeproj/project.xcworkspace/xcuserdata/matt.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Stapler.xcodeproj/xcuserdata/matt.xcuserdatad/xcschemes/xcschememanagement.plist b/Stapler.xcodeproj/xcuserdata/matt.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 392ce4c..0000000 --- a/Stapler.xcodeproj/xcuserdata/matt.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - Stapler.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128.png new file mode 100644 index 0000000..c657c59 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128@2x.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..0e08f17 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_128x128@2x.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16.png new file mode 100644 index 0000000..74fe4bd Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16@2x.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..9bd4bde Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_16x16@2x.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256.png new file mode 100644 index 0000000..0e08f17 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256@2x.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..3c36761 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_256x256@2x.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32.png new file mode 100644 index 0000000..9bd4bde Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32@2x.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..d5d5f45 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_32x32@2x.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512.png new file mode 100644 index 0000000..3c36761 Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512.png differ diff --git a/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512@2x.png b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..e9537ef Binary files /dev/null and b/Stapler/Assets.xcassets/StapledDocument.iconset/icon_512x512@2x.png differ diff --git a/Stapler/Stapler.entitlements b/Stapler/Stapler.entitlements index 0cb7136..3481a5e 100644 --- a/Stapler/Stapler.entitlements +++ b/Stapler/Stapler.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.files.all + com.apple.security.files.bookmarks.app-scope com.apple.security.files.downloads.read-only diff --git a/Stapler/StaplerApp.swift b/Stapler/StaplerApp.swift index 15e17da..d24da21 100644 --- a/Stapler/StaplerApp.swift +++ b/Stapler/StaplerApp.swift @@ -3,8 +3,86 @@ import UniformTypeIdentifiers import Quartz import os +// Define an enum for the different document opening scenarios +enum DocumentOpeningScenario { + case launchedWithDocument + case resumedBySystem + case openedThroughFileMenu + case openedFromFinderWhileRunning + case unknown +} + +// Modify the AppDelegate to work with the new AppStateManager class AppDelegate: NSObject, NSApplicationDelegate { -// nothing needed! FFS + func setupDefaultCommandKeyDelay() { + if UserDefaults.standard.object(forKey: "CommandKeyDelay") == nil { + UserDefaults.standard.set(0, forKey: "CommandKeyDelay") // Default wait 0ms + } + } + + func setupDefaultShowNewDocumentSelector() { + if UserDefaults.standard.object(forKey: "ShowNewDocumentSelector") == nil { + UserDefaults.standard.set(true, forKey: "ShowNewDocumentSelector") // Default to Show + } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + setupDefaultCommandKeyDelay() + setupDefaultShowNewDocumentSelector() + + // Check if we should show the new document selector and if no documents are already open + if !UserDefaults.standard.bool(forKey: "ShowNewDocumentSelector") && NSDocumentController.shared.documents.isEmpty { + // If not, create a new blank document + DispatchQueue.main.async { + NSDocumentController.shared.newDocument(nil) + } + } + } + + func application(_ application: NSApplication, open urls: [URL]) { + // Handle opening of documents from Finder + for url in urls { + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { (document, documentWasAlreadyOpen, error) in + if let error = error { + print("Error opening document: \(error.localizedDescription)") + } + } + } + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + let unsavedDocuments = NSDocumentController.shared.documents.filter { $0.isDocumentEdited } + + if unsavedDocuments.isEmpty { + return .terminateNow + } + + for document in unsavedDocuments { + let panel = NSSavePanel() + panel.nameFieldStringValue = document.fileURL?.lastPathComponent ?? "Untitled.stapled" + + let response = panel.runModal() + + if response == .OK { + if let url = panel.url { + do { + try document.write(to: url, ofType: UTType.staplerDocument.identifier) + } catch { + let alert = NSAlert() + alert.messageText = "Error Saving Document" + alert.informativeText = "Failed to save the document: \(error.localizedDescription)" + alert.addButton(withTitle: "OK") + alert.runModal() + return .terminateCancel + } + } + } else { + return .terminateCancel + } + } + + return .terminateNow + } } class AppStateManager: ObservableObject { @@ -37,18 +115,22 @@ struct AliasItem: Identifiable, Codable, Hashable { do { let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) if isStale { + // If the bookmark is stale, we need to create a new one _ = try url.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: nil, relativeTo: nil) + if let aliasItem = try? AliasItem(id: id, url: url) { + return aliasItem.resolveURL() + } + } + if !url.startAccessingSecurityScopedResource() { + print("Failed to access security scoped resource") + return nil } return url } catch { - print("StaplerApp: Alias: Error resolving bookmark: \(error)") + print("Error resolving bookmark: \(error)") return nil } } - - static func == (lhs: AliasItem, rhs: AliasItem) -> Bool { - lhs.id == rhs.id && lhs.bookmarkData == rhs.bookmarkData - } } struct StaplerDocument: FileDocument, Equatable { @@ -56,7 +138,6 @@ struct StaplerDocument: FileDocument, Equatable { static var writableContentTypes: [UTType] { [.staplerDocument] } var fileURL: URL? - var aliases: [AliasItem] init() { @@ -77,6 +158,7 @@ struct StaplerDocument: FileDocument, Equatable { let data = try Data(contentsOf: url) let decodedData = try JSONDecoder().decode(StaplerDocumentData.self, from: data) self.aliases = decodedData.aliases + self.fileURL = url } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { @@ -99,6 +181,7 @@ struct StaplerDocumentData: Codable { class StaplerViewModel: ObservableObject { @Published var document: StaplerDocument @Published var errorMessage: String? + @Published var hasUnsavedChanges: Bool = false init(document: StaplerDocument) { self.document = document @@ -107,12 +190,14 @@ class StaplerViewModel: ObservableObject { func addAlias(_ alias: AliasItem) { document.aliases.append(alias) sortAliases() + hasUnsavedChanges = true objectWillChange.send() } func removeAliases(at offsets: IndexSet) { document.aliases.remove(atOffsets: offsets) sortAliases() + hasUnsavedChanges = true objectWillChange.send() } @@ -152,25 +237,35 @@ class StaplerViewModel: ObservableObject { func updateFromDocument(_ newDocument: StaplerDocument) { self.document = newDocument + hasUnsavedChanges = false objectWillChange.send() } - func addAliasesViaFileSelector() { + func addAliasesViaFileSelector() -> Bool { let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.canChooseFiles = true if panel.runModal() == .OK { + var addedAliases = false for url in panel.urls { do { let newAlias = try AliasItem(url: url) addAlias(newAlias) + addedAliases = true } catch { handleError(error) } } + + if addedAliases { + hasUnsavedChanges = true + objectWillChange.send() + return true + } } + return false } func handleError(_ error: Error) { @@ -218,14 +313,17 @@ class QuickLookPreviewController: NSObject, QLPreviewPanelDataSource, QLPreviewP struct ContentView: View { @ObservedObject private var viewModel: StaplerViewModel @Binding var document: StaplerDocument + @Binding var hasSelection: Bool @State private var selection = Set() @State private var showingErrorAlert = false @FocusState private var isViewFocused: Bool private let quickLookPreviewController = QuickLookPreviewController() - @EnvironmentObject private var appStateManager: AppStateManager - init(document: Binding) { + @Environment(\.appStateManager) private var appStateManager + + init(document: Binding, hasSelection: Binding) { self._document = document + self._hasSelection = hasSelection self.viewModel = StaplerViewModel(document: document.wrappedValue) } @@ -237,13 +335,28 @@ struct ContentView: View { .resizable() .frame(width: 20, height: 20) Text(alias.name) + Spacer() + } + .padding(.vertical, 3) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + toggleSelection(for: alias) + launchAlias(alias) } + .onTapGesture(count: 1) { + toggleSelection(for: alias) + } + } + .onChange(of: selection) { newValue in + hasSelection = !newValue.isEmpty } + .listStyle(InsetListStyle()) .frame(minHeight: 200) } .frame(minWidth: 300, minHeight: 200) .focused($isViewFocused) .onDrop(of: [.fileURL], isTargeted: nil) { providers in + let wasEmpty = viewModel.document.aliases.isEmpty for provider in providers { _ = provider.loadObject(ofClass: URL.self) { url, error in if let error = error { @@ -253,6 +366,11 @@ struct ContentView: View { do { let newAlias = try AliasItem(url: url) viewModel.addAlias(newAlias) + if wasEmpty { + updateDocument() + } else { + document.aliases = viewModel.document.aliases + } } catch { viewModel.handleError(error) } @@ -269,21 +387,31 @@ struct ContentView: View { dismissButton: .default(Text("OK")) ) } - .onChange(of: viewModel.errorMessage) { oldValue, newValue in + .onChange(of: viewModel.errorMessage) { newValue in showingErrorAlert = newValue != nil } - .onChange(of: document) { oldValue, newValue in + .onChange(of: document) { newValue in viewModel.updateFromDocument(newValue) } - .onKeyPress(.return) { - launchSelected() - return .handled - } - .onKeyPress(.space) { - showQuickLook() - return .handled - } +// .onKeyPress(.return) { +// launchSelected() +// return .handled +// } +// .onKeyPress(.space) { +// showQuickLook() +// return .handled +// } .onAppear { + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 36 { // Return key + launchSelected() + } else if event.keyCode == 49 { // Space key + showQuickLook() + return nil + } + return event + } + setupNotificationObservers() DispatchQueue.main.async { self.isViewFocused = true @@ -291,9 +419,58 @@ struct ContentView: View { appStateManager.hasActiveDocument = true } .onDisappear { + updateDocument() removeNotificationObservers() appStateManager.hasActiveDocument = false } + .modifier(KeyPressModifier(launchAction: launchSelected, quickLookAction: showQuickLook)) + } + + private func toggleSelection(for alias: AliasItem) { + if selection.contains(alias.id) { + selection.remove(alias.id) + } else { + selection.insert(alias.id) + } + } + + private func launchAlias(_ alias: AliasItem) { + if let url = alias.resolveURL() { + defer { + url.stopAccessingSecurityScopedResource() + } + + let coordinator = NSFileCoordinator() + var error: NSError? + coordinator.coordinate(readingItemAt: url, options: .withoutChanges, error: &error) { url in + NSWorkspace.shared.open(url) + } + if let error = error { + viewModel.handleError(error) + } + } + } + + // Define a custom modifier to handle key presses + struct KeyPressModifier: ViewModifier { + let launchAction: () -> Void + let quickLookAction: () -> Void + + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content + .onKeyPress(.return) { + launchAction() + return .handled + } + .onKeyPress(.space) { + quickLookAction() + return .handled + } + } else { + content + } + } } private func showQuickLook() { @@ -315,8 +492,9 @@ struct ContentView: View { private func setupNotificationObservers() { NotificationCenter.default.addObserver(forName: .addAlias, object: nil, queue: .main) { _ in - viewModel.addAliasesViaFileSelector() - updateDocument() + if viewModel.addAliasesViaFileSelector() { + updateDocument() + } } NotificationCenter.default.addObserver(forName: .removeAlias, object: nil, queue: .main) { _ in removeSelectedAliases() @@ -338,12 +516,16 @@ struct ContentView: View { private func removeSelectedAliases() { let indicesToRemove = viewModel.document.aliases.indices.filter { selection.contains(viewModel.document.aliases[$0].id) } - viewModel.removeAliases(at: IndexSet(indicesToRemove)) - selection.removeAll() - updateDocument() + if indicesToRemove.count != 0 { + viewModel.removeAliases(at: IndexSet(indicesToRemove)) + selection.removeAll() + updateDocument() + } } private func launchSelected() { + guard !NSEvent.modifierFlags.contains(.command) else { return } + if selection.isEmpty { // If no items are selected, launch all items viewModel.launchAliases(at: IndexSet(integersIn: 0.. DocumentOpeningScenario { let currentEvent = NSApplication.shared.currentEvent - let eventTypeValue = currentEvent?.subtype.rawValue - let isOpenedFromFinder = currentEvent != nil && currentEvent?.type == .appKitDefined && - (eventTypeValue == 1 || eventTypeValue == 10) + let isOpenedFromFinder = currentEvent != nil && currentEvent?.type == .appKitDefined && currentEvent?.subtype.rawValue == NSEvent.EventSubtype.applicationActivated.rawValue - if isOpenedFromFinder { -// logger.info("Document opened from Finder") - + if appStateManager.wasJustLaunched && isOpenedFromFinder { + return .launchedWithDocument + } else if isOpenedFromFinder && NSApp.isActive { + return .openedFromFinderWhileRunning + } else if NSApp.isActive { + return .openedThroughFileMenu + } else if ProcessInfo.processInfo.isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 10, minorVersion: 15, patchVersion: 0)) { + // Check if the app was resumed by the system (macOS 10.15+) + return .resumedBySystem + } else { + return .unknown + } + } + + private func handleLaunchedWithDocument(_ url: URL) { + DispatchQueue.main.asyncAfter(deadline: .now() + commandKeyDelay) { let commandKeyPressed = NSEvent.modifierFlags.contains(.command) if !commandKeyPressed { - // Launch all items and close the document - DispatchQueue.main.async { - do { - let document = try StaplerDocument(contentsOf: url) - let viewModel = StaplerViewModel(document: document) - viewModel.launchAliases(at: IndexSet(integersIn: 0.. some Scene { self.commands { @@ -527,7 +799,48 @@ extension Scene { } } -#Preview { - ContentView(document: .constant(StaplerDocument())) - .environmentObject(AppStateManager()) +extension StaplerViewModel { + func markDocumentAsEdited() { + if let document = NSDocumentController.shared.document(for: document.fileURL ?? URL(fileURLWithPath: "/")) { + document.updateChangeCount(.changeDone) + } + } +} + +extension UserDefaults { + @objc dynamic var commandKeyDelay: Int { + get { integer(forKey: "CommandKeyDelay") } + set { set(newValue, forKey: "CommandKeyDelay") } + } +} + +extension UTType { + static var staplerDocument: UTType { + UTType(exportedAs: "com.gingerbeardman.Stapler.stapled") + } } + +private struct AppStateManagerKey: EnvironmentKey { + static let defaultValue = AppStateManager() +} + +struct AppStateManagerModifier: ViewModifier { + let appStateManager: AppStateManager + + func body(content: Content) -> some View { + #if os(macOS) + if #available(macOS 14.0, *) { + return AnyView(content.environmentObject(appStateManager)) + } else { + return AnyView(content.environment(\.appStateManager, appStateManager)) + } + #else + return AnyView(content.environment(\.appStateManager, appStateManager)) + #endif + } +} + +//#Preview { +// ContentView(document: .constant(StaplerDocument())) +// .environmentObject(AppStateManager()) +//}