今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

Image Compression Mac Application Development

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 Effect

UI Description:

  1. 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;
  2. Select path button, to choose files or directories;
  3. TinyPng API Key, for inputting the API key obtained from the TinyPNG website for interface calls.
  4. 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;
  5. Indicator, to show that compression is in progress;
  6. Start compression button, to get the compressible images in the folder, call the compression interface to compress, and output after compression;

Code Implementation#

  1. 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"
     }
 }

  1. 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:

Postman Upload Verification 1

Then switched to Body, selected Binary, added an image, clicked Send, and saw that the interface returned success, as shown below:

Postman Upload Verification 2

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.

  1. Before uploading, check if a compressible object is selected,
  2. Check if the APIKEY is entered,
  3. Show the indicator,
  4. 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,
  5. Check if the file is a supported compressible format; TinyPNG supports compressing images in png, jpg, jpeg, and webp formats; other file formats will not be processed,
  6. If it's a supported compressible file, call the compression method, and after successful compression, update the progress in the bottom ContentTextView,
  7. 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:

Upload Compression Demonstration Effect

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)
            }
        }
    }
}

  1. 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.

  1. Note that if the save location already exists, it should be removed.
  2. Note that the Content-Type in the HTTP header should be set to application/json; if not set, the download will fail with a content type error.
  3. 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:

Open Read/Write Permission Illustration

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:

PageCallback.gif

The complete code has been uploaded to GitHub: MWImageCompressUtil, link: https://github.com/mokong/MWImageCompressUtil

References#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.