Image Compression Mac App Development#
Background#
Three years ago, there was a project BatchProssImage that used Python to batch compress images. When I tried to use it again recently, I realized I had forgotten how to use it. Thus, the idea of turning this Python tool into a simple Mac app came about.
Process#
The idea is simple: I remembered that this tool used the TinyPNG API for compression, so I would develop a Mac client that calls the compression interface to export photos. Let's get started.
First, where does the UI for the Mac client come from? There was a previous project OtoolAnalyse that analyzed useless classes and methods in Mach-O files, which used the LinkMap UI. I thought, yes, I could use this method. Upon opening the project, I found it was in Objective-C, so I decided to rewrite it in Swift.
UI Implementation#
Thinking about the basic functionalities needed:
- Select files || directory
- Select export directory
- Start compression
- Show compression progress
- Oh, and one more thing, input for TinyPNG API key
Then I considered whether selecting an export directory was necessary. Previously, when I used other apps, selecting an export location interrupted the ongoing operations. For someone with decision-making difficulties, every time I had to consider where to export was a problem: should I create a new folder? What would happen if I chose the same directory?
I changed it to a check button, defaulting to replace in the same directory, because the intended use case is to select the project folder, scan the images in the folder, compress them, and then directly replace the original files. If the check is unchecked, an output folder will be created in the selected directory, and the compressed images will be output there, thus avoiding the hassle of selecting an export directory.
So the final effect looks like this:
UI Description:
- Path of the file to be compressed, used to display the selected path—if multiple files are selected, it shows that multiple files have been selected; if a single file or folder is selected, it shows the path;
- Select path button, to choose files or directories;
- TinyPng API Key, for inputting the API key obtained from the TinyPNG website for interface calls.
- Button for replacing the file path in the same directory after compression (the name of this button is a bit long [facepalm]), default selected, when selected, the compressed image directly replaces the original image; when unchecked, the compressed image is output to the output folder at the same level as the selected directory;
- Indicator, used to show that compression is in progress;
- Start compression button, to get the compressible images in the folder, call the start compression interface to compress, and output after compression;
Code Implementation#
- Logic for the select path button click event, supporting multiple selections, supporting directory selection, updating the file path display after selection
fileprivate var fileUrls: [URL]? // Selected file paths
@IBAction func choosePathAction(_ sender: Any) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true // Support multiple file selection
panel.canChooseDirectories = true // Can choose directories
panel.canChooseFiles = true // Can choose files
panel.begin { response in
if response == .OK {
self.fileUrls = panel.urls
self._privateUpdateFilePathLabelDisplay()
}
}
}
/// Update the label text for path display
fileprivate func _privateUpdateFilePathLabelDisplay() {
guard let fileUrls = fileUrls else {
// Default display
filePath.stringValue = "Path of the file to be compressed"
return
}
if fileUrls.count == 1 {
// Indicates a single file || folder is selected
filePath.stringValue = "Selected: " + (fileUrls.first?.absoluteString ?? "")
}
else {
filePath.stringValue = "Multiple files selected"
}
}
- Upload logic implementation
The upload logic first needs to know how TinyPNG uploads. Opening the TinyPNG API reference, we can see that it supports uploading via HTTP
, RUBY
, PHP
, NODE.JS
, PYTHON
, JAVA
, and .NET
. Except for HTTP
, the others provide precompiled libraries, so here we can only use the HTTP
method to upload.
First, think about what fields are needed for the image uploads done in previous projects, then browse the documentation to find and verify these fields.
It was confirmed that the upload domain is https://api.tinify.com/shrink
; authentication is required for uploads, and the method is HTTP Basic Auth
. The format is the obtained APIKEY, plus api:APIKEY
, then base64 encode it to get a string xxx, prepend Basic xxx
to the string, and finally place it in the HTTP header's Authorization
; the image data needs to be placed in the body for upload.
Before getting hands-on, I verified whether this interface works correctly. I opened Postman, created a new interface with the link https://api.tinify.com/shrink
, in post
format, added a key in Headers as Authorization
, value as Basic Base64EncodeStr(api:YourAPIKey)
, as follows:
Then switched to Body, selected Binary, added an image, clicked Send, and saw that the interface returned success, as follows:
This indicates that the upload compression interface works correctly. Now, let's implement similar logic in the app:
Create an upload class TinyPNGUploadService
, using Alamofire
to upload files.
Note 1: During upload, an error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"
kept occurring. After investigation, it was found that Mac app network requests need to have the Outgoing Connections(Client)
option checked under Target -> Signing & Capabilities in the App Sandbox section.
Note 2: The method AF.upload(multipartFormData..
cannot be used, otherwise it will throw an error Status Code: 415
, which took a long time to debug...
import Foundation
import AppKit
import Alamofire
let kTinyPNGCompressHost: String = "https://api.tinify.com/shrink"
public struct TinyPNGUploadService {
/// Upload image
/// - Parameter url: URL of the image to be uploaded
static func uploadFile(with url: URL, apiKey: String, responseCallback: ((UploadResponseItem?) -> Void)?) {
let needBase64Str = "api:" + apiKey
let authStr = "Basic " + needBase64Str.toBase64()
let header: HTTPHeaders = [
"Authorization": authStr,
]
AF.upload(url, to: kTinyPNGCompressHost, method: .post, headers: header)
.responseString(completionHandler: { response in
print(response)
// Fixed-Me:
responseCallback?(nil)
})
}
}
extension String {
func fromBase64() -> String? {
guard let data = Data(base64Encoded: self) else {
return nil
}
return String(data: data, encoding: .utf8)
}
func toBase64() -> String {
return Data(self.utf8).base64EncodedString()
}
}
Then, when the start compression button is clicked, call this encapsulated upload method.
- Before uploading, check if a compressible object is selected,
- Check if the APIKEY is entered,
- Show the indicator
- Iterate through the selected file paths; if it is a path, iterate through the files in the path; if it is a file, check directly
- Check if the file is a supported compressible format. TinyPNG supports compressing images in
png
,jpg
,jpeg
, andwebp
formats; other file formats are not processed. - If it is a supported file, call the compression method, and after successful compression, update the progress in the bottom ContentTextView.
- After all images are compressed, hide the indicator.
@IBAction func compressAction(_ sender: Any) {
guard let urls = fileUrls, urls.count > 0 else {
_privateShowAlert(with: "Please select the path to compress")
return
}
let apiKey = keyTF.stringValue
guard apiKey.count > 0 else {
_privateShowAlert(with: "Please enter the TinyPNG APIKey")
return
}
_privateIncatorAnimate(true)
let group = DispatchGroup()
let fileManager = FileManager.default
for url in urls {
let urlStr = url.absoluteString
if urlStr.hasSuffix("/") {
// "/" at the end indicates a directory
let dirEnumator = fileManager.enumerator(at: url, includingPropertiesForKeys: nil)
while let subFileUrl = dirEnumator?.nextObject() as? URL {
print(subFileUrl)
if _privateIsSupportImageType(subFileUrl.pathExtension) {
group.enter()
_privateCompressImage(with: subFileUrl, apiKey: apiKey) {
group.leave()
}
}
}
}
else if _privateIsSupportImageType(url.pathExtension) {
print(url)
group.enter()
_privateCompressImage(with: url, apiKey: apiKey) {
group.leave()
}
}
}
group.notify(queue: DispatchQueue.main) {
self._privateIncatorAnimate(false)
}
}
fileprivate func _privateIncatorAnimate(_ isShow: Bool) {
indicatorView.isHidden = !isShow
if isShow {
indicatorView.startAnimation(self)
}
else {
indicatorView.stopAnimation(self)
}
}
/// Call API to compress image
fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
let str = url.absoluteString + " compression completed\n"
self.resultOutput += str
self.contentTextView.string = self.resultOutput
callback?()
})
}
/// Check if the image format is supported for compression
fileprivate func _privateIsSupportImageType(_ typeStr: String) -> Bool {
let supportLists: [String] = [
"jpeg",
"JPEG",
"jpg",
"JPG",
"png",
"PNG",
"webp",
"WEBP",
]
if supportLists.contains(typeStr) {
return true
}
else {
return false
}
}
/// Alert dialog
fileprivate func _privateShowAlert(with str: String) {
let alert = NSAlert()
alert.messageText = str
alert.addButton(withTitle: "OK")
alert.beginSheetModal(for: NSApplication.shared.keyWindow!)
}
After running, select an image, click start compression, and the final effect is as follows:
Well, 30% completed, the upload compression part is done, but let's take a look at the data returned by the upload interface.
{
"input": {
"size": 2129441,
"type": "image/png"
},
"output": {
"size": 185115,
"type": "image/png",
"width": 750,
"height": 1334,
"ratio": 0.0869,
"url": "https://api.tinify.com/output/59dt7ar44cvau1tmnhpfhp42f35bdpd7"
}
}
In the returned data after compression, the input contains the original image size and type, while the output contains the compressed image data, including size, type, width, height, compression ratio, and image link. It can be seen that the returned data includes a link to the compressed image, so the remaining part is to download the compressed image and save it to the specified folder.
Since we need to use the returned data, we declare a model class to parse the returned data as follows:
import Foundation
struct UploadResponseItem: Codable {
var input: UploadReponseInputItem
var output: UploadResponseOutputItem
}
struct UploadReponseInputItem: Codable {
var size: CLongLong
var type: String
}
struct UploadResponseOutputItem: Codable {
var size: CLongLong
var type: String
var width: CLongLong
var height: CLongLong
var ratio: Double
var url: String
}
Then modify the method in the upload class TinyPNGUploadService
to parse into the model class and return the model class as a callback, as follows:
public struct TinyPNGUploadService {
/// Upload image
/// - Parameter url: URL of the image to be uploaded
static func uploadFile(with url: URL, apiKey: String, responseCallback: ((UploadResponseItem?) -> Void)?) {
let needBase64Str = "api:" + apiKey
let authStr = "Basic " + needBase64Str.toBase64()
let header: HTTPHeaders = [
"Authorization": authStr,
]
AF.upload(url, to: kTinyPNGCompressHost, method: .post, headers: header)
// .responseString(completionHandler: { response in
// print(response)
// responseCallback?(nil)
// })
.responseDecodable(of: UploadResponseItem.self) { response in
switch response.result {
case .success(let item):
responseCallback?(item)
case .failure(let error):
print(error)
responseCallback?(nil)
}
}
}
}
- Implementation of the download logic
Next, let's look at the implementation of the download logic. First, we go back to the TinyPNG API reference and see that in the Example download request
, the example shows that downloading also requires Authorization
(although it is not actually needed because you can directly open the URL in a private browser). However, to be safe, we still follow the example and add Authorization
in the header.
Since both uploads and downloads require Authorization
, we encapsulate the method to generate Authorization
and place it in the String extension. Since both upload and download need to call this method, we extract the extension into a separate class String_Extensions
, as follows:
import Foundation
public extension String {
func tinyPNGAuthFormatStr() -> String {
let needBase64Str = "api:" + self
let authStr = "Basic " + needBase64Str.toBase64()
return authStr
}
func fromBase64() -> String? {
guard let data = Data(base64Encoded: self) else {
return nil
}
return String(data: data, encoding: .utf8)
}
func toBase64() -> String {
return Data(self.utf8).base64EncodedString()
}
}
Then modify the upload class to generate authStr
as follows:
let authStr = apiKey.tinyPNGAuthFormatStr()
Next, create a download class, TinyPNGDownloadService
. The download method requires three parameters: the URL of the image to download, the location to save after downloading, and the TinyPNG API key.
- Note that if the save location already exists, it should be removed.
- Note that the
Content-Type
in the HTTP header should be set toapplication/json
; if not set, the download will fail with a content type error. - Note that the download return cannot be printed using
responseString
because the string is PNG data, which will print a long string of unreadable characters.
The final code is as follows:
import Foundation
import AppKit
import Alamofire
public struct TinyPNGDownloadService {
/// Download image
/// - Parameters:
/// - url: The link of the image to download
/// - destinationURL: The location to save the downloaded image
/// - apiKey: TinyPNG API Key
/// - responseCallback: Callback for the result
static func downloadFile(with url: URL, to destinationURL: URL, apiKey: String, responseCallback: (() -> Void)?) {
let authStr = apiKey.tinyPNGAuthFormatStr()
let header: HTTPHeaders = [
"Authorization": authStr,
"Content-type": "application/json"
]
let destination: DownloadRequest.Destination = { _, _ in
return (destinationURL, [.createIntermediateDirectories, .removePreviousFile])
}
AF.download(url, method: .post, headers: header, to: destination)
.response { response in
switch response.result {
case .success(_):
responseCallback?()
case .failure(let error):
print(error)
responseCallback?()
}
}
}
}
Next, consider when to call the download. After the upload is completed, we can obtain the link to download, and before displaying that it has been completed, we should first download it locally.
The download file directory depends on whether the check button is selected. If selected, it is a replacement, and the current file URL can be returned directly; if not selected, a new output directory will be added to the same directory, and it will be saved in the output folder.
The code is as follows:
fileprivate var isSamePath: Bool = true // Default is the same path
/// Check button selection or not
@IBAction func checkBtnAction(_ sender: NSButton) {
print(sender.state)
isSamePath = (sender.state == .on)
}
/// Call API to compress image
fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
if let tempUrlStr = uploadResItem?.output.url,
let tempUrl = URL(string: tempUrlStr) {
let destinationUrl = self._privateGetDownloadDestinationPath(from: url)
TinyPNGDownloadService.downloadFile(with: tempUrl,
to: destinationUrl,
apiKey: apiKey) {
self._privateUpdateContentOutDisplay(with: url)
callback?()
}
}
else {
self._privateUpdateContentOutDisplay(with: url)
callback?()
}
})
}
/// Update output display
fileprivate func _privateUpdateContentOutDisplay(with url: URL) {
let str = url.absoluteString + " compression completed\n"
self.resultOutput += str
self.contentTextView.string = self.resultOutput
}
/// Get the directory to save the downloaded file
fileprivate func _privateGetDownloadDestinationPath(from url: URL) -> URL {
if isSamePath {
// Directly replace the original file
return url
}
else {
// Create an output folder in the file directory and place it in output
let fileName = url.lastPathComponent
let subFolderPath = String(format: "output/%@", fileName)
let destinationUrl = URL(fileURLWithPath: subFolderPath, relativeTo: url)
return destinationUrl
}
}
After running and debugging, first try the case of replacing the same file. The download was successful, but it reported an error downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "“IMG_2049.PNG” couldn’t be removed because you don’t have permission to access it."
indicating no permission to write to the local file. Similarly, the Target -> Signing & Capabilities section needs to be modified to change the File Access option under App Sandbox to User Selected File
, with permissions set to Read/Write
, as follows:
After trying again, it was found that replacing the same file was successful.
Next, try saving to the output directory. Another error occurred: downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “output” in the folder “CompressTestFolder”.
Again, it was a permission issue. This had me stuck for a long time, and I couldn't create a folder. After researching, I found this answer (Cannot Create New Directory in MacOS app). Mac apps in Sandbox mode cannot automatically create directories. The solutions provided include:
Depending on your use case you can
- disable the sandbox mode
- let the user pick a folder by opening an "Open" dialog
- enable read/write in some other protected user folder (like Downloads, etc.) or
- create the TestDir directly in your home directory without using any soft linked folder
Following the provided solutions, I opted for the simplest one: I removed the Sandbox mode, deleted the App Sandbox module under Target -> Signing & Capabilities, and after debugging again, I was able to create the folder successfully.
Optimization: After completing the above steps, the overall effect can already be achieved, but for users, it is not very intuitive. On one hand, there are two steps in between, upload and download, and users may prefer to have feedback for each step; on the other hand, there is no intuitive sense of the final compression effect. They only see that a certain step is completed, but the degree of compression is not shown. It is already known that after a successful upload, the original image and the compressed image sizes and compression ratio will be returned, so further optimization can be done.
- After uploading and compressing, display that compression is completed, and the size reduced by xx%.
- After downloading and saving to the folder, display that writing is completed, and the final size is approximately: xxKb.
- Save the original image size and the difference from the compressed size for each step, and after all images are compressed, display the total size reduced compared to before.
fileprivate var totalCompressSize: CLongLong = 0 // Total size reduced
@IBAction func compressAction(_ sender: Any) {
...
group.notify(queue: DispatchQueue.main) {
self.resultOutput += String(format: "\n Total: Compared to before, a total of %ldKb was compressed", self.totalCompressSize/1024)
self.contentTextView.string = self.resultOutput
self._privateIncatorAnimate(false)
}
}
/// Call API to compress image
fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
let compressSize = (uploadResItem?.input.size ?? 0) - (uploadResItem?.output.size ?? 0)
self.totalCompressSize += compressSize
self._privateUpdateContentOutDisplay(with: url, isCompressCompleted: false, item: uploadResItem?.output)
if let tempUrlStr = uploadResItem?.output.url,
let tempUrl = URL(string: tempUrlStr) {
let destinationUrl = self._privateGetDownloadDestinationPath(from: url)
TinyPNGDownloadService.downloadFile(with: tempUrl,
to: destinationUrl,
apiKey: apiKey) {
self._privateUpdateContentOutDisplay(with: url, isCompressCompleted: true, item: uploadResItem?.output)
callback?()
}
}
else {
callback?()
}
})
}
/// Update output display
fileprivate func _privateUpdateContentOutDisplay(with url: URL, isCompressCompleted: Bool, item: UploadResponseOutputItem?) {
var suffixStr: String = ""
if let outputItem = item {
let ratio = 1.0 - outputItem.ratio
suffixStr = "Compression completed, reduced: " + String(format: "%.0f", ratio*100) + "% of the size\n"
if isCompressCompleted {
suffixStr = String(format: "Writing completed, final size approximately:%.ldKb \n", outputItem.size/1024)
}
}
else {
suffixStr = "Compression completed\n"
if isCompressCompleted {
suffixStr = "Writing completed\n"
}
}
let str = url.absoluteString + suffixStr
self.resultOutput += str
self.contentTextView.string = self.resultOutput
}
The complete effect is as follows:
The complete code has been uploaded to GitHub: MWImageCompressUtil, link: https://github.com/mokong/MWImageCompressUtil