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.
+
## 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())
+//}