Image Compression Mac App Development#
Background#
Three years ago, there was a project BatchProcessImage, which was a batch image compression tool written in Python. When I tried to use it again recently, I realized I had forgotten how to use it. Thus, I had the idea to turn this Python tool into a simple Mac app.
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 — which analyzed useless classes and methods in Mach-O files — that 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 necessary features:
- Select files || directories
- Select export directory
- Start compression
- Display compression progress
- Oh, and one more thing, input for TinyPNG API key
Then I considered whether selecting an export directory was necessary. When I previously used other apps, choosing an export location interrupted the ongoing operation, and for someone who struggles with decision-making, deciding where to export each time was a hassle. Should I create a new folder? What would happen if I selected 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 not selected, 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 files 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 files in the same directory after compression (the name of this button is a bit long [facepalm]), selected by default; when selected, the compressed images directly replace the original images; when not selected, the compressed images are output to an output folder at the same level as the selected directory;
- Indicator, to show that compression is in progress;
- Start compression button, to get the compressible images in the folder, call the compression interface to compress, and output after compression;
Code Implementation#
- Logic for the select path button click event, supporting multiple selections and 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 // Supports 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 the path display
fileprivate func _privateUpdateFilePathLabelDisplay() {
guard let fileUrls = fileUrls else {
// Default display
filePath.stringValue = "File path 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"
}
}
- Implementation of upload logic
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 uploading images in previous projects, then browse the documentation to find and validate these fields.
It was confirmed that the upload domain is https://api.tinify.com/shrink
; authentication is required for uploading, 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.
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 shown below:
Then switched to Body, selected Binary, added an image, clicked Send, and saw that the interface returned success, as shown below:
This indicates that the upload compression interface works correctly, and then I implemented similar logic in the app:
Create an upload class TinyPNGUploadService
and use Alamofire
to upload the file method.
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 in Target -> Signing & Capabilities under App Sandbox.
Note 2: The method AF.upload(multipartFormData..
cannot be used, otherwise it will report 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 clicking the start compression button, call this 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's a path, iterate through the files in the path; if it's a file, check it directly,
- Check if the file is a supported compressible format; TinyPNG supports compressing images in
png
,jpg
,jpeg
, andwebp
formats; other file formats will not be processed, - If it's a supported compressible 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 TinyPNG's 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% has been 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"
}
}
The returned data after compression shows the input size and type, and 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 an image link after compression, so the remaining part is to download the compressed image and save it to the specified folder.
Since the returned data will be used, I will 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 in the 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 download logic
Next, let's look at the implementation of the download logic. First, go back to the TinyPNG API reference and see that in the Example download request
, the example download also includes Authorization
(although it's not actually needed, because you can directly open the URL in a private browser). However, to be safe, I still added Authorization
in the header according to the example.
Since both upload and download require Authorization
, I encapsulated the method to generate Authorization
and placed it in the String extension. Moreover, since both upload and download need to call this method, I extracted 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
, which requires three parameters for the download method: 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 results in 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 to 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 complete, the link to download should be obtained, and before displaying that it has been completed, the image should be downloaded locally.
The download file directory depends on whether the check button is selected. If selected, it replaces the original file; if not selected, it creates an output directory in the same directory and saves it there.
The code is as follows:
fileprivate var isSamePath: Bool = true // Default is the same path
/// Check button selection
@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 succeeded, but saving 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."
There was no permission to write to the local file. Similarly, the Target -> Signing & Capabilities needs to be modified under App Sandbox to change the File Access option to User Selected File
, with permissions set to Read/Write
, as shown below:
After trying again, it was found that replacing the same file was successful.
Next, try saving to the output directory. Another error was reported: downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “output” in the folder “CompressTestFolder”.
Again, there was no permission. This had me stuck for a long time, unable to create the folder. After researching, I found this answer (Cannot Create New Directory in MacOS app). Mac apps in Sandbox mode cannot automatically create directories. The suggested solutions 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 suggested solutions, I opted for the simplest one: I removed the Sandbox mode, deleted the App Sandbox module in Target -> Signing & Capabilities, and after debugging again, I could successfully create the folder.
Optimization: After completing the above steps, the overall effect is already functional, 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 step is completed, but the degree of compression is not evident. It is already known that the original and compressed image sizes and compression ratios will be returned after a successful upload, so further optimizations can be made.
- 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, 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