Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Config/products/macpacker.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@
{
"version": "1.0.1",
"items": [
{
"type": "feat",
"title": {
"en": "Share a newly compressed archive",
"zh-Hans": "分享刚压缩的归档文件",
"fr": "Partager une archive nouvellement compressée",
"de": "Neu erstelltes Archiv teilen",
"it": "Condividi un archivio appena compresso",
"ja": "圧縮したアーカイブを共有",
"fa": "اشتراک‌گذاری بایگانی تازه فشرده‌شده",
"pl": "Udostępnij nowo spakowane archiwum",
"pt-BR": "Compartilhar um arquivo recém-compactado",
"ru": "Поделиться только что созданным архивом",
"uk": "Поділитися щойно стисненим архівом",
"es-MX": "Compartir un archivo recién comprimido",
"ko": "새로 압축한 아카이브 공유",
"nl": "Nieuw gecomprimeerd archief delen",
"vi": "Chia sẻ kho lưu trữ vừa nén",
"tr": "Yeni sıkıştırılan arşivi paylaş"
},
"issues": [
"234"
]
},
{
"type": "fix",
"highlight": true,
Expand Down
9 changes: 9 additions & 0 deletions FinderExtension/FinderSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class FinderSync: FIFinderSync {
case .extractToChosenFolder: #selector(extractToChosenFolder(_:))
case .addToArchive: #selector(addToArchive(_:))
case .compressToZip: #selector(compressToZip(_:))
case .compressAndShare: #selector(compressAndShare(_:))
case .compressToDatedZip: #selector(compressToDatedZip(_:))
case .compressTo7z: #selector(compressTo7z(_:))
case .compressEachSeparately: #selector(compressEachSeparately(_:))
Expand Down Expand Up @@ -230,6 +231,9 @@ class FinderSync: FIFinderSync {
}
return String(localized: .commonCompressTo(name))

case .compressAndShare:
return String(localized: "Compress and Share…", comment: "Finder menu action that creates a zip and opens macOS sharing for it")

case .compressEachSeparately:
return String(localized: .settingsCompressEachItemSeparately)

Expand Down Expand Up @@ -332,6 +336,11 @@ class FinderSync: FIFinderSync {
communicateWithMainApp(item: .compressToZip)
}

@objc func compressAndShare(_ sender: Any?) {
log.notice("Finder menu: Compress and Share")
communicateWithMainApp(item: .compressAndShare)
}

@objc func compressTo7z(_ sender: Any?) {
log.notice("Finder menu: Compress to 7z")
communicateWithMainApp(item: .compressTo7z)
Expand Down
8 changes: 6 additions & 2 deletions MacPacker/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
handler = AppUrlExtractHereHandler(catalog: appState.catalog, engineSelector: appState.engineSelector)
case .extractToFolder:
handler = AppUrlExtractToFolderHandler(catalog: appState.catalog, engineSelector: appState.engineSelector)
case .compress:
case .compress, .compressAndShare:
handler = AppUrlCompressHandler(catalog: appState.catalog, engineSelector: appState.engineSelector)
case .addToArchive:
handler = AppUrlAddToArchiveHandler()
Expand Down Expand Up @@ -187,7 +187,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
#else
let showsExtractionDemo = false
#endif
let launchedToOpenSomething = opensWindow || showsExtractionDemo
let launchedToShare = pendingOpenURLs.contains { url in
url.scheme == UrlParser.appScheme
&& UrlParser().parse(appUrl: url)?.action == .compressAndShare
}
let launchedToOpenSomething = opensWindow || showsExtractionDemo || launchedToShare

// make sure that at least one window will be shown even if it is empty
if !launchedToOpenSomething {
Expand Down
6 changes: 5 additions & 1 deletion MacPacker/Core/UrlHandling/AppUrlCompressHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ class AppUrlCompressHandler: AppUrlHandler {
)
let dest = CompressDestination.unique(named: name, in: appUrl.target)
if await writeArchive(appUrl.files, to: dest, catalog: self.catalog, engineSelector: self.engineSelector) {
NSWorkspace.shared.activateFileViewerSelecting([dest])
if appUrl.action == .compressAndShare {
ArchiveSharePresenter.shared.present(dest)
} else {
NSWorkspace.shared.activateFileViewerSelecting([dest])
}
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions MacPacker/Features/ArchiveWindow/ArchiveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Stephan Arenswald on 16.07.26.
//

import AppKit
import Core
import SwiftUI

Expand Down Expand Up @@ -54,6 +55,15 @@ struct ArchiveCommands: Commands {
}
.keyboardShortcut("s", modifiers: [.command, .shift])

Button {
guard let state = Self.frontArchiveState(),
let url = state.url, !state.isSaving,
!state.hasPendingChanges else { return }
ArchiveSharePresenter.shared.present(url, from: NSApp.keyWindow?.contentView)
} label: {
Text("Share Archive…", comment: "File menu command for sharing the saved archive")
}

// Deletion is the standard Edit ▸ Delete menu item, handled by the
// archive table (ArchiveTableView.delete(_:)) — not a File command.
}
Expand Down
2 changes: 2 additions & 0 deletions MacPacker/Features/Settings/IntegrationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ private struct FinderMenuItemToggle: View {
Text(.commonAddToArchive)
case .compressToZip:
Text(.settingsCompressToZip)
case .compressAndShare:
Text("Compress and Share…", comment: "Optional Finder menu entry for sharing a newly compressed zip")
case .compressTo7z:
Text(.settingsCompressTo7Z)
case .extractToChosenFolder:
Expand Down
142 changes: 142 additions & 0 deletions MacPacker/Features/Sharing/ArchiveSharePresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// ArchiveSharePresenter.swift
// MacPacker
//
// A finished archive is handed to the system's sharing services. Finder can
// launch the app without a window, so that path supplies its own small anchor.
//

import AppKit

@MainActor
final class ArchiveSharePresenter: NSObject, @preconcurrency NSSharingServicePickerDelegate, NSSharingServiceDelegate, NSWindowDelegate {
static let shared = ArchiveSharePresenter()

private var archive: URL?
private var picker: NSSharingServicePicker?
private var chosenService: NSSharingService?
private var panel: NSPanel?
private var shareButton: NSButton?

func present(_ url: URL, from view: NSView? = nil) {
guard FileManager.default.fileExists(atPath: url.path) else { return }
dismiss()
archive = url

if let view, view.window != nil {
showPicker(relativeTo: view)
return
}

let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 112),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
panel.title = String(localized: "Share Archive…", comment: "Title of the small window used to share a newly compressed archive")
panel.isReleasedWhenClosed = false
panel.delegate = self

let label = NSTextField(labelWithString: url.lastPathComponent)
label.lineBreakMode = .byTruncatingMiddle
label.alignment = .center
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let button = NSButton(
title: String(localized: "Share…", comment: "Button that opens macOS sharing services for the finished archive"),
target: self, action: #selector(shareClicked(_:)))
button.bezelStyle = .rounded
let content = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 112))
let stack = NSStackView(views: [label, button])
stack.orientation = .vertical
stack.alignment = .centerX
stack.spacing = 14
stack.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -20),
stack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: content.centerYAnchor),
label.widthAnchor.constraint(lessThanOrEqualToConstant: 320),
])
panel.contentView = content
panel.minSize = NSSize(width: 360, height: 112)
panel.setContentSize(NSSize(width: 360, height: 112))
self.panel = panel
shareButton = button
panel.center()
panel.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)

// The window remains usable if AppKit declines to open a picker outside
// a mouse event; clicking Share then opens the same native picker.
DispatchQueue.main.async { [weak self, weak button] in
guard let self, let button, self.shareButton === button else { return }
self.showPicker(relativeTo: button)
}
}

@objc private func shareClicked(_ sender: NSButton) {
showPicker(relativeTo: sender)
}

private func showPicker(relativeTo view: NSView) {
guard let archive else { return }
let picker = NSSharingServicePicker(items: [archive])
picker.delegate = self
self.picker = picker
picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
}

func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker,
delegateFor sharingService: NSSharingService) -> (any NSSharingServiceDelegate)? {
guard sharingServicePicker === picker else { return nil }
chosenService = sharingService
return self
}

func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker,
didChoose sharingService: NSSharingService?) {
guard sharingServicePicker === picker else { return }
if let sharingService {
chosenService = sharingService
} else {
dismiss()
}
}

func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
guard sharingService === chosenService else { return }
dismiss()
}

func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: any Error) {
guard sharingService === chosenService else { return }
if let panel {
NSAlert(error: error).beginSheetModal(for: panel)
} else {
NSApp.presentError(error)
}
}

func windowWillClose(_ notification: Notification) {
picker?.delegate = nil
picker?.close()
picker = nil
chosenService = nil
panel = nil
shareButton = nil
archive = nil
}

private func dismiss() {
picker?.delegate = nil
picker?.close()
picker = nil
chosenService = nil
let panel = panel
self.panel = nil
shareButton = nil
archive = nil
panel?.delegate = nil
panel?.close()
}
}
12 changes: 12 additions & 0 deletions MacPacker/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
{
"sourceLanguage": "en",
"strings": {
"Compress and Share…": {
"comment": "Finder menu action that creates a zip and opens macOS sharing for it; Optional Finder menu entry for sharing a newly compressed zip",
"extractionState": "manual"
},
"Share Archive…": {
"comment": "File menu command and small window title for sharing an archive",
"extractionState": "manual"
},
"Share…": {
"comment": "Button that opens macOS sharing services for the finished archive",
"extractionState": "manual"
},
"appExtractionInProgress": {
"comment": "Title of the alert shown when the user quits while an extraction is running.",
"extractionState": "manual",
Expand Down
5 changes: 3 additions & 2 deletions Modules/Sources/FinderMenu/AppUrl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum AppUrlAction: String, Sendable {
case extractToFolder
case extractTo
case compress
case compressAndShare
case compressEach
case compressContents
case addToArchive
Expand All @@ -33,8 +34,8 @@ public extension AppUrlAction {
switch self {
case .extractHere, .extractTo:
true
case .extractToFolder, .open, .compress, .compressEach,
.compressContents, .addToArchive:
case .extractToFolder, .open, .compress, .compressAndShare,
.compressEach, .compressContents, .addToArchive:
false
}
}
Expand Down
16 changes: 11 additions & 5 deletions Modules/Sources/FinderMenu/FinderMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
case addToArchive
/// Straight to `<name>.zip`, no questions.
case compressToZip
/// Make a zip, then offer the new archive to macOS sharing services.
case compressAndShare
/// `<name> 2026-09-10 14.30.zip` — a quick snapshot before an edit.
case compressToDatedZip
/// Straight to `<name>.7z`, no questions.
Expand All @@ -51,7 +53,7 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
case .extractHere, .extractToChosenFolder, .compressToZip, .compressToDatedZip:
true
case .open, .extractToFolder, .addToArchive, .compressTo7z,
.compressEachSeparately, .compressFolderContents:
.compressAndShare, .compressEachSeparately, .compressFolderContents:
false
}
}
Expand All @@ -68,7 +70,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
files + folders > 1
case .compressFolderContents:
files == 0 && folders == 1
case .addToArchive, .compressToZip, .compressToDatedZip, .compressTo7z:
case .addToArchive, .compressToZip, .compressAndShare,
.compressToDatedZip, .compressTo7z:
true
}
}
Expand All @@ -78,8 +81,9 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
switch self {
case .open, .extractHere, .extractToFolder, .extractToChosenFolder:
true
case .addToArchive, .compressToZip, .compressToDatedZip, .compressTo7z,
.compressEachSeparately, .compressFolderContents:
case .addToArchive, .compressToZip, .compressAndShare,
.compressToDatedZip, .compressTo7z, .compressEachSeparately,
.compressFolderContents:
false
}
}
Expand All @@ -100,6 +104,7 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
case .extractToChosenFolder: .extractTo
case .addToArchive: .addToArchive
case .compressToZip, .compressToDatedZip, .compressTo7z: .compress
case .compressAndShare: .compressAndShare
case .compressEachSeparately: .compressEach
case .compressFolderContents: .compressContents
}
Expand All @@ -109,7 +114,8 @@ public enum FinderMenuItem: String, CaseIterable, Sendable {
/// produce one directly. The writer picks the format from it.
public var archiveExtension: String? {
switch self {
case .compressToZip, .compressToDatedZip, .compressEachSeparately, .compressFolderContents: "zip"
case .compressToZip, .compressAndShare, .compressToDatedZip,
.compressEachSeparately, .compressFolderContents: "zip"
case .compressTo7z: "7z"
case .open, .extractHere, .extractToFolder, .extractToChosenFolder, .addToArchive: nil
}
Expand Down
8 changes: 8 additions & 0 deletions Modules/Tests/CoreTests/AppUrlTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ extension AllCoreTests {
#expect(try AppUrl(url: url, scheme: scheme) == request)
}

@Test("Compress and Share survives the Finder-to-app URL handoff")
func compressAndShareRoundTrip() throws {
let request = AppUrl(action: .compressAndShare,
files: [URL(fileURLWithPath: "/Users/me/photos")],
target: URL(fileURLWithPath: "/Users/me"), format: "zip")
#expect(try AppUrl(url: #require(request.url(scheme: scheme)), scheme: scheme) == request)
}

@Test("The dated archive gets the name the menu showed, even after a minute boundary")
func datedNameMatchesTheMenu() throws {
// the menu opens a second before the minute turns; the archive is
Expand Down
Loading