diff --git a/.github/workflows/create-release-with-artifact.yml b/.github/workflows/create-release-with-artifact.yml new file mode 100644 index 0000000..8ab1406 --- /dev/null +++ b/.github/workflows/create-release-with-artifact.yml @@ -0,0 +1,94 @@ +name: build-macos +on: + push: + # Not used once the Drop is rejected in the released binary for some reason. + # tags: ["v*"] + branches: + - check/ci + +permissions: + contents: write + +jobs: + build-sign-notarize: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + # Import certificates into Keychain + - uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.DEV_ID_CERT_P12 }} + p12-password: ${{ secrets.DEV_ID_CERT_PASSWORD }} + + # Xcode build (signed) + - name: Build release + run: | + xcodebuild -project QuickShelf.xcodeproj \ + -scheme QuickShelf \ + -config Release \ + -archivePath build/QuickShelf.xcarchive \ + clean archive \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO + env: + TEAM_ID: ${{ secrets.TEAM_ID }} + + - name: Codesign + run: | + xcrun codesign --force --deep \ + --timestamp \ + --options runtime \ + --entitlements QuickShelf/QuickShelf.entitlements \ + --sign "$SIGN_ID" "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" + env: + TEAM_ID: ${{ secrets.TEAM_ID }} + SIGN_ID: ${{ secrets.DEVID_COMMON_NAME }} + # ZIP the .app + - name: Zip app + run: | + ditto -c -k --keepParent "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" QuickShelf.zip + + # Submit with notarytool (save Keychain profile on the fly) + - name: Store notary credentials + run: | + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "$APPLE_ID" \ + --team-id "$TEAM_ID" \ + --password "$APPLE_PASS" + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASS: ${{ secrets.APPLE_PASS }} + TEAM_ID: ${{ secrets.TEAM_ID }} + + - name: Submit to notarization + run: | + xcrun notarytool submit QuickShelf.zip \ + --keychain-profile "notary-profile" --wait + + # Staple + - name: Staple ticket + run: xcrun stapler staple "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" + + # Gatekeeper verification and re-zipping + - name: Validate + run: | + spctl --assess --type execute --verbose=4 "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" + xcrun stapler validate "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" + ditto -c -k --keepParent "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" QuickShelf.zip + + # - name: Create Release and Upload Assets + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # uses: softprops/action-gh-release@v2 + # with: + # files: QuickShelf.zip + # draft: true + # generate_release_notes: true + + # Upload Deliverables + - uses: actions/upload-artifact@v4 + with: + name: QuickShelf-macOS + path: QuickShelf.zip diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8ab1406..9b18d89 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1,94 +1,19 @@ -name: build-macos +name: create-release + on: push: - # Not used once the Drop is rejected in the released binary for some reason. - # tags: ["v*"] - branches: - - check/ci - -permissions: - contents: write + tags: ["v*"] jobs: - build-sign-notarize: - runs-on: macos-15 - + build: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - # Import certificates into Keychain - - uses: apple-actions/import-codesign-certs@v3 - with: - p12-file-base64: ${{ secrets.DEV_ID_CERT_P12 }} - p12-password: ${{ secrets.DEV_ID_CERT_PASSWORD }} - - # Xcode build (signed) - - name: Build release - run: | - xcodebuild -project QuickShelf.xcodeproj \ - -scheme QuickShelf \ - -config Release \ - -archivePath build/QuickShelf.xcarchive \ - clean archive \ - ARCHS="arm64 x86_64" \ - ONLY_ACTIVE_ARCH=NO - env: - TEAM_ID: ${{ secrets.TEAM_ID }} - - - name: Codesign - run: | - xcrun codesign --force --deep \ - --timestamp \ - --options runtime \ - --entitlements QuickShelf/QuickShelf.entitlements \ - --sign "$SIGN_ID" "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" - env: - TEAM_ID: ${{ secrets.TEAM_ID }} - SIGN_ID: ${{ secrets.DEVID_COMMON_NAME }} - # ZIP the .app - - name: Zip app - run: | - ditto -c -k --keepParent "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" QuickShelf.zip - - # Submit with notarytool (save Keychain profile on the fly) - - name: Store notary credentials - run: | - xcrun notarytool store-credentials "notary-profile" \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APPLE_PASS" - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASS: ${{ secrets.APPLE_PASS }} - TEAM_ID: ${{ secrets.TEAM_ID }} - - - name: Submit to notarization - run: | - xcrun notarytool submit QuickShelf.zip \ - --keychain-profile "notary-profile" --wait - - # Staple - - name: Staple ticket - run: xcrun stapler staple "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" - - # Gatekeeper verification and re-zipping - - name: Validate - run: | - spctl --assess --type execute --verbose=4 "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" - xcrun stapler validate "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" - ditto -c -k --keepParent "build/QuickShelf.xcarchive/Products/Applications/QuickShelf.app" QuickShelf.zip - - # - name: Create Release and Upload Assets - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # uses: softprops/action-gh-release@v2 - # with: - # files: QuickShelf.zip - # draft: true - # generate_release_notes: true - - # Upload Deliverables - - uses: actions/upload-artifact@v4 - with: - name: QuickShelf-macOS - path: QuickShelf.zip + - name: Checkout + uses: actions/checkout@v5 + - name: Create draft release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v2 + with: + draft: true + generate_release_notes: true \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8552e5a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SlowLab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QuickShelf.xcodeproj/project.pbxproj b/QuickShelf.xcodeproj/project.pbxproj index 6610beb..fe6cbf5 100644 --- a/QuickShelf.xcodeproj/project.pbxproj +++ b/QuickShelf.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 6B31AE332E43FF0600C4653D /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 6B31AE322E43FF0600C4653D /* LaunchAtLogin */; }; 6B3437242E3B6B4F00C52D3C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 6B3437232E3B6B4F00C52D3C /* KeyboardShortcuts */; }; 6B400A802E174A6E00505989 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 6B400A7F2E174A6E00505989 /* MenuBarExtraAccess */; }; + 6B5A8B6B2E4D281A00E1293D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6B5A8B6A2E4D281A00E1293D /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,9 +36,22 @@ 6B400A6B2E1749FC00505989 /* QuickShelfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QuickShelfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 6B5A8BDC2E4D357900E1293D /* Exceptions for "QuickShelf" folder in "QuickShelf" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 6B400A522E1749FB00505989 /* QuickShelf */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 6B400A552E1749FB00505989 /* QuickShelf */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 6B5A8BDC2E4D357900E1293D /* Exceptions for "QuickShelf" folder in "QuickShelf" target */, + ); path = QuickShelf; sourceTree = ""; }; @@ -59,6 +73,7 @@ buildActionMask = 2147483647; files = ( 6B31AE332E43FF0600C4653D /* LaunchAtLogin in Frameworks */, + 6B5A8B6B2E4D281A00E1293D /* Sparkle in Frameworks */, 6B400A802E174A6E00505989 /* MenuBarExtraAccess in Frameworks */, 6B3437242E3B6B4F00C52D3C /* KeyboardShortcuts in Frameworks */, ); @@ -124,6 +139,7 @@ 6B400A7F2E174A6E00505989 /* MenuBarExtraAccess */, 6B3437232E3B6B4F00C52D3C /* KeyboardShortcuts */, 6B31AE322E43FF0600C4653D /* LaunchAtLogin */, + 6B5A8B6A2E4D281A00E1293D /* Sparkle */, ); productName = QuickShelf; productReference = 6B400A532E1749FB00505989 /* QuickShelf.app */; @@ -211,6 +227,7 @@ 6B400A7E2E174A6E00505989 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, 6B3437222E3B6B4F00C52D3C /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, 6B31AE312E43FF0600C4653D /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, + 6B5A8B692E4D281A00E1293D /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6B400A542E1749FB00505989 /* Products */; @@ -412,10 +429,11 @@ CODE_SIGN_ENTITLEMENTS = QuickShelf/QuickShelf.entitlements; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QuickShelf/Info.plist; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -423,7 +441,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.0.9; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.slowlab.QuickShelf; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -441,10 +459,11 @@ CODE_SIGN_ENTITLEMENTS = QuickShelf/QuickShelf.entitlements; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QuickShelf/Info.plist; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -452,7 +471,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.0.9; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.slowlab.QuickShelf; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,6 +611,14 @@ minimumVersion = 1.2.1; }; }; + 6B5A8B692E4D281A00E1293D /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.7.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -610,6 +637,11 @@ package = 6B400A7E2E174A6E00505989 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */; productName = MenuBarExtraAccess; }; + 6B5A8B6A2E4D281A00E1293D /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 6B5A8B692E4D281A00E1293D /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6B400A4B2E1749FB00505989 /* Project object */; diff --git a/QuickShelf/ContentView.swift b/QuickShelf/ContentView.swift index 69d8f41..cb63da7 100644 --- a/QuickShelf/ContentView.swift +++ b/QuickShelf/ContentView.swift @@ -6,12 +6,19 @@ // import SwiftUI +import QuickLook struct ContentView: View { @Environment(\.openSettings) private var openSettings + @AppStorage("preview.side") + private var sideRaw: String = PreviewSide.right.rawValue + + private var side: PreviewSide { PreviewSide(rawValue: sideRaw) ?? .right } @State private var inputDir = "" @State private var items: [ShelfItem] = [] + @State private var selection = Set() + @State private var previewUrl: URL? var body: some View { VStack(alignment: .leading) { @@ -28,11 +35,21 @@ struct ContentView: View { } Text("Items") .font(.title3) - List { + List(selection: $selection) { ForEach(items.standardSorted(), id: \.url) { item in - ShelfItemView(item: item) + ShelfItemView( + item: item, + isSelected: selection.contains(item.url), + onPreview: { url in + if let anchor = NSApp.keyWindow { + SlidePanelPreview.shared.show(url: url, beside: anchor, side: side, + size: NSSize(width: 380, height: 300)) + } + } + ) .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } .listRowSeparatorTint(Color.white.opacity(0.3)) + .tag(item.url) .draggable(item) } } @@ -54,6 +71,7 @@ struct ContentView: View { } .padding(.all, 18) .onAppear { + selection = [] if let data = UserDefaults.standard.data(forKey: "user_selected_dir") { var stale = false if let url = try? URL(resolvingBookmarkData: data, @@ -65,6 +83,9 @@ struct ContentView: View { } } } + .onDisappear { + SlidePanelPreview.shared.hide() + } } private func openPanel() { diff --git a/QuickShelf/Info.plist b/QuickShelf/Info.plist new file mode 100644 index 0000000..5a12853 --- /dev/null +++ b/QuickShelf/Info.plist @@ -0,0 +1,12 @@ + + + + + SUEnableInstallerLauncherService + + SUPublicEDKey + GcHmGpnmUkMBMXNvjP8Y/YXQI+vgFPQ8+HK20pS8Qlk= + SUFeedURL + https://pub-5f38224dc1ff432da0f0a37e338ec061.r2.dev/sparkle/appcast.xml + + diff --git a/QuickShelf/QuickShelf.entitlements b/QuickShelf/QuickShelf.entitlements index 19afff1..d363e1c 100644 --- a/QuickShelf/QuickShelf.entitlements +++ b/QuickShelf/QuickShelf.entitlements @@ -6,5 +6,12 @@ com.apple.security.files.user-selected.read-write + com.apple.security.network.client + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/QuickShelf/QuickShelfApp.swift b/QuickShelf/QuickShelfApp.swift index 30a22b4..3f095f4 100644 --- a/QuickShelf/QuickShelfApp.swift +++ b/QuickShelf/QuickShelfApp.swift @@ -62,6 +62,16 @@ struct QuickShelfApp: App { settingsItem.target = AppDelegate.shared menu.addItem(settingsItem) menu.addItem(.separator()) + + let updaterItem = NSMenuItem( + title: "Check for Updates…", + action: #selector(AppDelegate.checkForUpdates(_:)), + keyEquivalent: "" + ) + updaterItem.target = AppDelegate.shared + menu.addItem(updaterItem) + menu.addItem(.separator()) + let quitItem = NSMenuItem( title: "Quit", action: #selector(AppDelegate.terminate(_:)), @@ -79,10 +89,16 @@ class AppDelegate: NSObject { static let shared = AppDelegate() var openSettings: OpenSettingsAction? + private let appUpdater = AppUpdater() + @objc func openPreferences(_ sender: Any?) { openSettings?() } + @objc func checkForUpdates(_ sender: Any?) { + appUpdater.checkForUpdates() + } + @objc func terminate(_ sender: Any?) { SecurityScope.shared.endAccess() NSApp.terminate(sender) diff --git a/QuickShelf/Support/Updater/AppUpdater.swift b/QuickShelf/Support/Updater/AppUpdater.swift new file mode 100644 index 0000000..6a7e710 --- /dev/null +++ b/QuickShelf/Support/Updater/AppUpdater.swift @@ -0,0 +1,25 @@ +// +// AppUpdater.swift +// QuickShelf +// +// Created by slowhand on 2025/08/16. +// + +import Sparkle + +final class AppUpdater { + private let notifier = NotificationBridge() + private lazy var delegate = UpdaterDelegate(notifier: notifier) + private lazy var updaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: delegate + ) + + init() { + delegate.updaterController = updaterController + notifier.configure() + } + + func checkForUpdates() { + updaterController.updater.checkForUpdates() + } +} diff --git a/QuickShelf/Support/Updater/NotificationBridge.swift b/QuickShelf/Support/Updater/NotificationBridge.swift new file mode 100644 index 0000000..ad5891c --- /dev/null +++ b/QuickShelf/Support/Updater/NotificationBridge.swift @@ -0,0 +1,29 @@ +// +// NotificationBridge.swift +// QuickShelf +// +// Created by slowhand on 2025/08/17. +// + +import UserNotifications + +final class NotificationBridge: NSObject, UNUserNotificationCenterDelegate { + func configure() { + UNUserNotificationCenter.current().delegate = self + } + func requestAuth() { + UNUserNotificationCenter.current() + .requestAuthorization(options: [.badge, .alert, .sound]) { _, _ in } + } + func postGentleUpdate(version: String) { + let content = UNMutableNotificationContent() + content.title = "Update available" + content.body = "Version \(version) is now available" + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: "QS_UPDATE", content: content, trigger: nil) + ) + } + func clearGentleUpdate() { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["QS_UPDATE"]) + } +} diff --git a/QuickShelf/Support/Updater/UpdaterDelegate.swift b/QuickShelf/Support/Updater/UpdaterDelegate.swift new file mode 100644 index 0000000..f8eafd8 --- /dev/null +++ b/QuickShelf/Support/Updater/UpdaterDelegate.swift @@ -0,0 +1,43 @@ +// +// UpdaterDelegate.swift +// QuickShelf +// +// Created by slowhand on 2025/08/17. +// + +import Sparkle + +final class UpdaterDelegate: NSObject, SPUStandardUserDriverDelegate, SPUUpdaterDelegate { + weak var updaterController: SPUStandardUpdaterController? + private let notifier: NotificationBridge + + init(notifier: NotificationBridge) { + self.notifier = notifier + } + + var supportsGentleScheduledUpdateReminders: Bool { true } + + func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem, + andInImmediateFocus immediateFocus: Bool) -> Bool { + return immediateFocus + } + + func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, + forUpdate update: SUAppcastItem, + state: SPUUserUpdateState) { + guard !handleShowingUpdate else { return } + if !state.userInitiated { notifier.postGentleUpdate(version: update.displayVersionString) } + } + + func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { + notifier.clearGentleUpdate() + } + + func standardUserDriverWillFinishUpdateSession() { + notifier.clearGentleUpdate() + } + + func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) { + notifier.requestAuth() + } +} diff --git a/QuickShelf/Views/Preview/SidePanelPreview.swift b/QuickShelf/Views/Preview/SidePanelPreview.swift new file mode 100644 index 0000000..d8708cb --- /dev/null +++ b/QuickShelf/Views/Preview/SidePanelPreview.swift @@ -0,0 +1,99 @@ +// +// SidePanelPreview.swift +// QuickShelf +// +// Created by slowhand on 2025/08/26. +// + +import AppKit +import QuickLookUI +import Carbon.HIToolbox + +final class SlidePanelPreview: NSObject { + static let shared = SlidePanelPreview() + + private var panel: NSPanel? + private var previewView: QLPreviewView? + private weak var anchorWindow: NSWindow? + private var closeEvent: Any? + + func show( + url: URL, + beside anchor: NSWindow, + side: PreviewSide, + size: NSSize = NSSize(width: 360, height: 300) + ) { + anchorWindow = anchor + let panel = self.panel == nil ? makePanel(size: size) : self.panel! + if self.panel == nil { self.panel = panel } + + ensurePreviewViewAlive() + previewView?.previewItem = url as NSURL + positionPanelBesideAnchor(panel: panel, anchor: anchor, side: side, size: size) + + // nonactivating + panel.orderFrontRegardless() + + // Close with the ESC key + if closeEvent == nil { + closeEvent = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if Int(event.keyCode) == kVK_Escape { + self?.panel?.orderOut(nil) + return nil + } + return event + } + } + } + + func hide() { + panel?.orderOut(nil) + } + + private func makePanel(size: NSSize) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.nonactivatingPanel, .titled, .closable, .hudWindow], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.hasShadow = true + panel.level = .floating + panel.collectionBehavior = [.moveToActiveSpace, .transient] + panel.isOpaque = false + panel.backgroundColor = .clear + panel.becomesKeyOnlyIfNeeded = true + return panel + } + + private func ensurePreviewViewAlive() { + if previewView == nil || (previewView?.window == nil && panel?.contentView == nil) { + let ql = QLPreviewView(frame: .zero, style: .normal) + ql?.autostarts = true + ql?.shouldCloseWithWindow = false + panel?.contentView = ql + previewView = ql + } + } + + private func positionPanelBesideAnchor(panel: NSPanel, anchor: NSWindow, side: PreviewSide, size: NSSize) { + let anchorFrame = anchor.frame + let screen = anchor.screen ?? NSScreen.main + let visibleFrame = screen?.visibleFrame ?? .infinite + + var origin = NSPoint(x: anchorFrame.maxX + 8, + y: anchorFrame.minY) + if side == .left { + var x = anchorFrame.minX - 8 - size.width + var y = anchorFrame.minY + + y = max(min(y, visibleFrame.maxY - size.height), visibleFrame.minY) + if x < visibleFrame.minX { x = visibleFrame.minX } + origin = NSPoint(x: x, y: y) + } + panel.setFrame(NSRect(origin: origin, size: size), display: true, animate: true) + } +} + diff --git a/QuickShelf/Views/Settings/GeneralView.swift b/QuickShelf/Views/Settings/GeneralView.swift index a270740..13dc655 100644 --- a/QuickShelf/Views/Settings/GeneralView.swift +++ b/QuickShelf/Views/Settings/GeneralView.swift @@ -9,14 +9,32 @@ import SwiftUI import KeyboardShortcuts import LaunchAtLogin +enum PreviewSide: String, CaseIterable, Identifiable { + case left, right + var id: String { rawValue } +} + struct GeneralView: View { + @AppStorage("preview.side") + private var sideRaw: String = PreviewSide.right.rawValue + var body: some View { VStack(alignment: .leading) { Text("General") .font(.title) .padding(.bottom, 18) LaunchAtLogin.Toggle() - .padding(.bottom, 18) + .padding(.bottom, 8) + Picker("Preview position", selection: Binding( + get: { PreviewSide(rawValue: sideRaw) ?? .right }, + set: { sideRaw = $0.rawValue } + )) { + Text("Left").tag(PreviewSide.left) + Text("Right").tag(PreviewSide.right) + } + .pickerStyle(.menu) + .fixedSize() + .padding(.bottom, 18) Text("Shortcuts") .font(.title) .padding(.bottom, 18) diff --git a/QuickShelf/Views/ShelfItemView.swift b/QuickShelf/Views/ShelfItemView.swift index c662ec1..a17f75b 100644 --- a/QuickShelf/Views/ShelfItemView.swift +++ b/QuickShelf/Views/ShelfItemView.swift @@ -11,6 +11,8 @@ struct ShelfItemView: View { @Environment(\.colorScheme) private var colorScheme let item: ShelfItem + let isSelected: Bool + let onPreview: (URL) -> Void var body: some View { HStack { @@ -29,21 +31,39 @@ struct ShelfItemView: View { } Text(item.url.lastPathComponent) .foregroundColor(colorScheme == .dark ? .primary : .white.opacity(0.7)) + Spacer() + if isSelected && !item.isDirectory { + Button { + onPreview(item.url) + } label: { + Image(systemName: "eye") + } + .help("Preview") + .buttonStyle(.borderless) + .keyboardShortcut(.space, modifiers: []) + } } .padding(.all, 8) } } #Preview("file") { - ShelfItemView(item: ShelfItem( - url: URL(filePath: "/path/to/fileName.txt")!, - isDirectory: false - )) + ShelfItemView( + item: ShelfItem( + url: URL(filePath: "/path/to/fileName.txt")!, + isDirectory: false + ), + isSelected: false, + onPreview: { url in print("Previewing: \(url)")} + ) } #Preview("directory") { - ShelfItemView(item: ShelfItem( - url: URL(filePath: "/path/to/folderName")!, - isDirectory: true - )) + ShelfItemView( + item: ShelfItem( + url: URL(filePath: "/path/to/folderName")!, + isDirectory: true + ), + isSelected: false, + onPreview: { url in print("Previewing: \(url)")}) } diff --git a/README.md b/README.md index f372cee..16f36bb 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,27 @@ QuickShelf Logo

QuickShelf

A lightning-fast macOS menu-bar “shelf” that shows any folder’s contents for instant drag & drop access.

+ latest version + license +platform + QuickShelf Demo -QuickShelf Demo - ## Features - 📁 Instant access to any folder's contents - 📦 Drag & drop support - ⚡ Lightweight and fast +- ⌨️ Hotkey Support +- 🔄 Start at login + +## Installation +Download the latest release from the [here](https://quickshelf-app.slowlab.dev/) + +## Requirements +- macOS 14.6 or later +- Apple Silicon or Intel processor +- 64-bit architecture ## License diff --git a/Resources/demo.gif b/Resources/demo.gif index 32f07aa..c45d783 100644 Binary files a/Resources/demo.gif and b/Resources/demo.gif differ