Build your first macOS app - PackageMachine

Why build this app

Code:https://github.com/gwh111/testcocoappswift

This tutorial is better for iOS developer to master build basic macOS app skills. Meanwhile, this app is also a useful tool for an iOS developer.

We know how package with Xcode. We use archive so that we can export .ipa file. Before submit to AppStore, we often offer .ipa file to tester to do test. When you do several projects at same time, you are easily to make mistake or a bit of trouble to do this work. And PackageMachine is a macos app that can help you manage your projects and export .ipa file with one click.

Here’s a tutorial for how to build such a macos app.

Build the app

Create project

First you need to open Xcode then to do File-New-Project.

then choose macOS-CocoaApp and tap next to create a CocoaApp template.
We give it a name “PackageMachine”. So now we have:

Draw views in Main.storyboard

Use Main.storyboard can help us see the project structure clearly and to do less code.
You can use library and search to find plugs you want quickly.

Then layout the view carefully.

It’s not hard to layout the display like this.

State IBOutlet

In swift it do not like ObjectiveC has .h file, we just declare IBOutlet in .swift.
We find ViewController.swift and do following statement. So that we can connect code to our storyboard layout later.

@IBOutlet var taskName: NSTextField!
@IBOutlet var projectPath: NSTextField!
@IBOutlet var projectName: NSTextField!
@IBOutlet var debugRelease: NSSegmentedControl!
@IBOutlet var exportOptionsPath: NSTextField!
@IBOutlet var ipaPath: NSTextField!
@IBOutlet var logTextField: NSTextField!
@IBOutlet var showInfoTextView: NSTextView!

Remember to open assistant editor in the middle and show the inspectors in Xcode.

Connect all the target one to one carefully. Once you have done, you can change display in storyboard with your code now.

Don’t forget to link button and segment control at same time.
For button, I use tag to distinguish the different path selected.

@IBAction func selectPath(_ sender: NSButton) {
        let tag=sender.tag
        print(tag)
}

Interaction method

We want to get project path with one click and show the document list. So we need to use NSOpenPanel() It has been realized by Apple, once you import Cocoa you can use all the base classes.
The selectPath(_ sender: NSButton) method can be realized as:

@IBAction func selectPath(_ sender: NSButton) {
        let tag=sender.tag
        print(tag)
        
        // 1. create Panel object
        let openPanel = NSOpenPanel()
        // 2. set button text
        openPanel.prompt = "Select"
        // 3. forbidden select file, we only need path.
        openPanel.canChooseFiles = true
        if tag==0||tag==2 {
            openPanel.canChooseFiles = false
        }
        // 4. set can choose directories
        openPanel.canChooseDirectories = true
        if tag==1 {
            openPanel.canChooseDirectories = false
            openPanel.allowedFileTypes=["plist"]
        }
        // 5. pop sheet method
        openPanel.beginSheetModal(for: self.view.window!) { (result) in
            // 6. when ok button clicked
            if result == NSApplication.ModalResponse.OK {
                // 7. get the current path
                let path=openPanel.urls[0].absoluteString.removingPercentEncoding!
                if tag==0 {
                    self.projectPath.stringValue=path
                    let array=path.components(separatedBy:"/")
                    if array.count>1{
                        let name=array[array.count-2]
                        print(array)
                        print(name as Any)
                        self.projectName.stringValue=name
                    }
                }else if tag==1 {
                    self.exportOptionsPath.stringValue=path
                }else{
                    self.ipaPath.stringValue=path
                }
            }
        }
    }

This method help us record all the paths and we will talk how to save all the data later. And we use path.compoents(separatedBy:"/") to separate string to array.

When segment control state changed, we can add a select to monitor the state.

debugRelease.action = #selector(segmentControlChanged(segmentControl:))
@objc func segmentControlChanged(segmentControl: NSSegmentedControl) {
        print(segmentControl.selectedSegment)
}

Start(Run shell task)

Before run task, we will check all the textview content is standard. Notice we use showNotice() method, and we will explain how to realize this later.

guard projectPath.stringValue != "" else {
      showNotice(str: "project path cannot empty", suc: false)
      return
}
guard projectName.stringValue != "" else {
      showNotice(str: "project name cannot empty", suc: false)
      return
}
guard ipaPath.stringValue != "" else {
      showNotice(str: "output ipa path cannot empty", suc: false)
      return
}

Can we run task now? The answer is no. We need something else to do.

We should replace some var in shell script with the path we set before by using replacingOccurrences method. The code can be see as:

let returnData = Bundle.main.path(forResource: "package", ofType: "sh")
let data = NSData.init(contentsOfFile: returnData!)
var str =  NSString(data:data! as Data, encoding: String.Encoding.utf8.rawValue)! as String
if debugRelease.selectedSegment==0 {
   str = str.replacingOccurrences(of: "DEBUG_RELEASE", with: "debug")
}else{
    str = str.replacingOccurrences(of: "DEBUG_RELEASE", with: "release")
}
str = str.replacingOccurrences(of: "NAME_PROJECT", with: nameStr)
str = str.replacingOccurrences(of: "PATH_PROJECT", with: projectStr)
str = str.replacingOccurrences(of: "PATH_PLIST", with: plistStr)
str = str.replacingOccurrences(of: "PATH_IPA", with: ipaStr)
str = str.replacingOccurrences(of: "file://", with: "")

And last step before running we need go to global thread to make sure the app will not get stuck when running our task.

Our code will default run in main thread, which is UI thread. Normally we should put time cost task in child thread so that the UI thread will not get stuck.

isLoadingRepo = true  //set sign
DispatchQueue.global(qos: .default).async {
		let task = Process()     // init NSTask
		
        // set task
        task.launchPath = "/bin/bash"    // absolute execution path
        
        // set command
        task.arguments = ["-c",str]
        task.terminationHandler = { proce in              // finish block
                self.isLoadingRepo = false    // recover sign
                //5. back to UI thread
                DispatchQueue.main.async(execute: {
                    self.logTextField.stringValue="finish running task...";
                })
        }
        self.captureStandardOutputAndRouteToTextView(task)
        task.launch()                // run task
        task.waitUntilExit()       // wait to finish
}

Then we create a Process() to run the shell script, and go back to main thread in terminationHandler block.
If user not set a path, we use default file to execute:

var plistStr:String=self.exportOptionsPath.stringValue
if plistStr.count<=0 {
    let homeDic=NSHomeDirectory().appending("/ProjectPackage/eo") as String
    plistStr=homeDic.appending("/"+"ExportOptions"+".plist")
}

So this part all the function code can be realize as:

@IBAction func start(_ sender: Any) {
        guard projectPath.stringValue != "" else {
      		showNotice(str: "project path cannot empty", suc: false)
	   	    return
		}
		guard projectName.stringValue != "" else {
        	showNotice(str: "project name cannot empty", suc: false)
     	 	return
		}
		guard ipaPath.stringValue != "" else {
        	showNotice(str: "output ipa path cannot empty", suc: false)
      		return
		}
		
        if isLoadingRepo {
            showNotice(str: "is running last task", suc: false)
            return
        }
        isLoadingRepo = true 
        
        var projectStr=self.projectPath.stringValue
        if projectStr.first=="/" {
        }else{
            projectStr="/"+projectStr
        }
        let nameStr=self.projectName.stringValue
        var plistStr:String=self.exportOptionsPath.stringValue
        if plistStr.count<=0 {
            let homeDic=NSHomeDirectory().appending("/ProjectPackage/eo") as String
            plistStr=homeDic.appending("/"+"ExportOptions"+".plist")
        }
        
        var ipaStr=self.ipaPath.stringValue
        if ipaStr.first=="/" {
        }else{
            ipaStr="/"+ipaStr
        }
        
        let returnData = Bundle.main.path(forResource: "package", ofType: "sh")
        let data = NSData.init(contentsOfFile: returnData!)
        var str =  NSString(data:data! as Data, encoding: String.Encoding.utf8.rawValue)! as String
        if debugRelease.selectedSegment==0 {
            str = str.replacingOccurrences(of: "DEBUG_RELEASE", with: "debug")
        }else{
            str = str.replacingOccurrences(of: "DEBUG_RELEASE", with: "release")
        }
        str = str.replacingOccurrences(of: "NAME_PROJECT", with: nameStr)
        str = str.replacingOccurrences(of: "PATH_PROJECT", with: projectStr)
        str = str.replacingOccurrences(of: "PATH_PLIST", with: plistStr)
        str = str.replacingOccurrences(of: "PATH_IPA", with: ipaStr)
        str = str.replacingOccurrences(of: "file://", with: "")
        print("result:\(str)");
        
        self.logTextField.stringValue="start running task...";
        
		DispatchQueue.global(qos: .default).async {
				let task = Process()     // init NSTask
    		    // set task
    		    task.launchPath = "/bin/bash"    // absolute execution path
    		    // set command
     		   task.arguments = ["-c",str]
     		   task.terminationHandler = { proce in              // finish block
            		    self.isLoadingRepo = false    // recover sign
         		       //5. back to UI thread
         		       DispatchQueue.main.async(execute: {
               		     self.logTextField.stringValue="finish running task...";
            		    })
  		      }
     		   self.captureStandardOutputAndRouteToTextView(task)
     		   task.launch()                // run task
     		   task.waitUntilExit()       // wait to finish
		}
        
    }

Monitor task output

We all know when we run task in terminal, it will output a lot of log to help us find error. So we need capture some log when we are running our shell script.

If you are carefully, you must find captureStandardOutputAndRouteToTextView(task) before in ***start(_ sender: Any) *** method. So now we realize this function:

extension ViewController{
    fileprivate func captureStandardOutputAndRouteToTextView(_ task:Process) {
        //1. set output standard pipe
        outputPipe = Pipe()
        task.standardOutput = outputPipe
        
        //2. waiting for notification
        outputPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
        
        //3. receive notification
        
        observe=NotificationCenter.default.addObserver(forName: NSNotification.Name.NSFileHandleDataAvailable, object: outputPipe.fileHandleForReading , queue: nil) { notification in
            
            //4. get pipe data, transform to string
            let output = self.outputPipe.fileHandleForReading.availableData
            let outputString = String(data: output, encoding: String.Encoding.utf8) ?? ""
            if outputString != ""{
                //5. goto main UI
                DispatchQueue.main.async {
                    
                    if self.isLoadingRepo == false {
                        let previousOutput = self.showInfoTextView.string
                        let nextOutput = previousOutput + "\n" + outputString
                        self.showInfoTextView.string = nextOutput
                        // scroll to bottom
                        let range = NSRange(location:nextOutput.utf8CString.count,length:0)
                        self.showInfoTextView.scrollRangeToVisible(range)
                        
                        if self.observe==nil {
                            return
                        }
                        NotificationCenter.default.removeObserver(self.observe!)
                        
                        return
                    }else{
                        let previousOutput = self.showInfoTextView.string
                        var nextOutput = previousOutput + "\n" + outputString as String
                        if nextOutput.count>5000 {
                            nextOutput=String(nextOutput.suffix(1000));
                        }
                        // scroll to bottom
                        let range = NSRange(location:nextOutput.utf8CString.count,length:0)
                        self.showInfoTextView.scrollRangeToVisible(range)
                        self.showInfoTextView.string = nextOutput
                    }
                }
            }
            
            if self.isLoadingRepo == false {
                return
            }
            //6. waiting for next notification
            self.outputPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
        }
    }
    
}

Pipe is used for serial port communication, to see more information, you can see https://developer.apple.com/documentation/foundation/pipe

Now you can run one task now, next we will add some codes to make it easy to use and talk how to write shell script.

Shell script

Learn write some shell script

You can open your terminal to do such test.
Output “hello world”:

a="hello world!"
num=2
echo "a is : $a num is : ${num}nd"

move file to another document and change name:

cp /Users/gwh/Documents/aaa123.jpg /Users/gwh/aaa1234.jpg

shell script in package machine

Now you master some grammar for shell script, let’s see what’s in PackageMachine’s shell script. I have added some notes between command line:

# !/bin/bash

# project name
project_name='NAME_PROJECT'
# project path
project_path='PATH_PROJECT'
# exportOptionsPlist path
plist_path='PATH_PLIST'
# debug release
debug_release='DEBUG_RELEASE'
# ipa output path
# create archive doc
output_path='PATH_IPA'
# xcarchive temp store path
archive_path="$output_path/archive"

appName='xx'
appId='xx'

packaging(){

# ***********configure
MWConfiguration=$debug_release
#date
MWDate=`date +%Y%m%d_%H%M`

# structure
xcodebuild archive \
-workspace "$project_path$project_name.xcworkspace" \
-scheme "$project_name" \
-configuration "$MWConfiguration" \
-archivePath "$archive_path/$project_name" \
clean \
build \
-derivedDataPath "$MWBuildTempDir"

# output ipa file
xcodebuild -exportArchive -exportOptionsPlist $plist_path -archivePath "$archive_path/$project_name.xcarchive" -exportPath $output_path/$appId

# move and rename
# mv /$output_path/$appId/LotteryShop.ipa /$output_path/$appId.ipa
time3=$(date "+%Y-%m-%d %H-%M-%S")
mv /$output_path/$appId/$project_name.ipa /$output_path/"$appId $time3.ipa"

# delete temp file
rm -r $output_path/$appId/
rm -r $output_path/archive/

}


group(){

appNames=($project_name)

appIds=($project_name)


if [[ $all -eq 0 ]]; then
echo "all=$all"

appNames=($project_name)

appIds=($project_name)

fi

i=0
while [[ i -lt ${#appIds[@]} ]]; do

appName=${appNames[i]}
appId=${appIds[i]}
let i++

echo $appName
# replace resource
# prepare

#package
packaging

done

open $output_path

}
#---------------------------------------------------------------------------------------------------------------------------------

#start
group

Read and Save

Once we create a task, we need to save it to local, so that we can recover when reopen the app.

Create store documents when launch

When we use the app in first time, i will create two documents to store data when in use.
The /ProjectPackage/eo is used to save ExportOptions.plist file and the /ProjectPackage/task is used to save tasks we will create when in use.

let taskPath=NSHomeDirectory().appending("/ProjectPackage/task") as String
        let eoPath=NSHomeDirectory().appending("/ProjectPackage/eo") as String
        
        let fileM=FileManager.default
        let existed:Bool=fileM.fileExists(atPath: taskPath, isDirectory: nil)
        if (existed==false) {
            do {
                try fileM.createDirectory(at: NSURL.fileURL(withPath: taskPath), withIntermediateDirectories: true, attributes: nil)
            }catch{
                
            }
        }
        let existed_eo:Bool=fileM.fileExists(atPath: eoPath, isDirectory: nil)
        if (existed_eo==false) {
            do {
                try fileM.createDirectory(at: NSURL.fileURL(withPath: eoPath), withIntermediateDirectories: true, attributes: nil)
            }catch{
                
            }
        }
//        print(read() as Any)
        
        //save exp
        let returnData = Bundle.main.path(forResource: "ExportOptions", ofType: "plist")
        let data = NSData.init(contentsOfFile: returnData!)
        var str =  NSString(data:data! as Data, encoding: String.Encoding.utf8.rawValue)! as String
        let homeDic=NSHomeDirectory().appending("/ProjectPackage/eo") as String
        let fileName:String=homeDic.appending("/"+"ExportOptions"+".plist")
        data!.write(toFile: fileName, atomically: true)

Save task

When we add a new task, we don’t want to configure it again. So we save it as template.

func saveCurrentTable(){
        let saveDic=readFromCurrentTable();
        let taskS=saveDic.object(forKey: "taskName") as! String
        if taskS.count<=0 {
            showNotice(str: "give a taskName please", suc: false)
            return;
        }
        showNotice(str: "create【"+taskS+"】success", suc: true)
        
        let homeDic=NSHomeDirectory().appending("/ProjectPackage/task") as String
        let fileName:String=homeDic.appending("/"+taskS+".plist")
        
        saveDic.write(toFile: fileName, atomically: true)
    }

The task we save as plist file, it looks as:

Read task

We can recover tasks we saved in documents we create before:

func read() ->[NSDictionary]? {
        let homeDic=NSHomeDirectory().appending("/ProjectPackage/task") as String
        print(homeDic)
        
        var taskList:[NSDictionary]=[]
        
        let fileM=FileManager.default
        do {
            let cintents1 = try fileM.contentsOfDirectory(atPath: homeDic)
//            print("cintents:\(cintents1.count)\n")
            
            for name in cintents1 {
                let fileName:String=homeDic.appending("/"+name)
                let taskDic=readFileName(fileName: fileName)
                if (taskDic) != nil{
                    taskList.insert(taskDic!, at: 0)
                }
            }
        } catch {
            
        }
        return taskList
    }

Task board

Task board is used to manage many different tasks. So we add a tableview in task board to display and let user select a task they want to use.

If you are an iOS developer, you must know how to create tableview. In macOS, it be named as NSTableView. And we will add a NSTableView to NSView.

func addBoard(){
        
        board=NSView.init()
        board.frame=self.view.visibleRect
        board.wantsLayer=true
        self.view.addSubview(board)
        
        let colorBoard:NSView=NSView.init()
        colorBoard.frame=board.visibleRect
        colorBoard.wantsLayer=true
        colorBoard.layer?.backgroundColor=NSColor.black.cgColor
        colorBoard.alphaValue=0.5;
        board.addSubview(colorBoard)
        
        let cancelBt:NSButton=NSButton.init()
        cancelBt.frame=board.visibleRect
        cancelBt.alphaValue=0;
        cancelBt.action = #selector(cancelBtTapped(bt:))
        board.addSubview(cancelBt)
        
        let leftBoard:NSView=NSView.init()
        leftBoard.frame=NSRect(x:0,y:0,width:300,height:board.visibleRect.height)
        leftBoard.wantsLayer=true
        leftBoard.layer?.backgroundColor=NSColor.white.cgColor
        board.addSubview(leftBoard)
        
        dataSource = read()
        
        scrollView.frame=NSRect(x:leftBoard.frame.origin.x,y:leftBoard.frame.origin.y+40,width:leftBoard.frame.size.width,height:leftBoard.frame.size.height-40)
//        tableView.delegate=(self as NSTableViewDelegate)
//        tableView.dataSource=(self as NSTableViewDataSource)
        board.addSubview(scrollView)
        
        let addBt:NSButton=NSButton.init()
        addBt.frame=NSRect(x:0,y:0,width:100,height:40)
        let str="add task" as String
        let attrTitle = NSMutableAttributedString.init(string: str)
        let titleRange = NSMakeRange(0, str.count)
        attrTitle.addAttributes([NSAttributedString.Key.foregroundColor: NSColor.black], range: titleRange)
        
        addBt.attributedTitle=attrTitle
        addBt.bezelStyle=NSButton.BezelStyle.regularSquare
        addBt.action = #selector(addBtTapped(bt:))
        board.addSubview(addBt)
        
        tableView.reloadData()
    }

Also we need to implement some Delegate and DataSource:

//MARK: - NSTableViewDataSource
extension ViewController: NSTableViewDataSource{
    func numberOfRows(in tableView: NSTableView) -> Int {
        guard let dataSource = dataSource else {
            return 0
        }
        return dataSource.count
    }
    
    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let rowView = tableView.rowView(atRow: row, makeIfNecessary: false)
        rowView?.isEmphasized = false
        let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "CustomCell"), owner: self) as! CustomCell
        let item = dataSource?[row]
        cell.row=row
        if row==selectIndex {
            cell.selected=true;
        }else{
            cell.selected=false;
        }
        cell.setContent(item: item)
        cell.callBackClosureFunction { (name, index) in
            print("name:\(name), index:\(index)")
            
            //delete from sandbox
            self.deleteAtIndex(index: index)
            
            self.dataSource?.remove(at: index)
            self.tableView.reloadData()
        }
        return cell
    }
    
    
    func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
        let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "CustomCell"), owner: self) as! CustomCell
        let item = dataSource?[row]
        cell.setContent(item: item)
        
        if tableView.tableColumns.count>0 {
            let tc = tableView.tableColumns[0]
            let gap: CGFloat = 10 //width outside of label
            cell.titleLabel.preferredMaxLayoutWidth = tc.width - gap
            cell.detailLabel.preferredMaxLayoutWidth = tc.width - gap
        }
        
        return cell.fittingSize.height
    }
    
}

//MARK: - NSTableViewDelegate
extension ViewController: NSTableViewDelegate{
    
    
    func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
        print("click at row \(row)")
        selectIndex=row
        displayCurrentTable()
        tableView.reloadData()
        return true
    }
    
    func tableViewSelectionDidChange(_ notification: Notification) {
        let itemsSelected = tableView.selectedRowIndexes.count
        
        if itemsSelected > 0 {
            let row = tableView.selectedRow
            tableView.deselectRow(row)
        }
    }
    
    
}

Auxiliary function

func alt(altStr:String)

Give a alt view to show notice:

func alt(altStr:String) {
        let alert:NSAlert=NSAlert.init()
        alert.addButton(withTitle: "OK")
        alert.messageText=altStr
        alert.beginSheetModal(for: self.view.window!) { (result) in
            print(result.rawValue)
            if result.rawValue==1000 {
                print("ok")
            }
        }
}

func showNotice(str:String, suc:Bool)

This notice will show in log board, and we use green and red color to separate error and success logs:

func showNotice(str:String, suc:Bool) {
        self.logTextField.stringValue=str
        if suc {
            let color: NSColor = NSColor.init(red: 18.0/255.0, green: 189.0/255.0, blue: 0, alpha: 1.0);
            self.logTextField.textColor=color
        }else{
            let color: NSColor = NSColor.init(red: 150.0/255.0, green: 0, blue: 0, alpha: 1.0);
            self.logTextField.textColor=color
        }
}

@IBAction func courseTapped(_ sender: Any)

@IBAction func courseTapped(_ sender: Any) {
        let returnData = Bundle.main.path(forResource: "README", ofType: "md")
        let data = NSData.init(contentsOfFile: returnData!)
        let str =  NSString(data:data! as Data, encoding: String.Encoding.utf8.rawValue)! as String
        self.showInfoTextView.string=str
}

Tips

Remember do not open App Sandbox in capabilities. If you do so, you cannot get local path to execute shell script. But if you want to let your app pass AppStore, you should open it.

The first time open .dmg you will get a warning that you cannot open an app from un-know developer. So you should goto security and safety to grant authorization.

Demonstration

When finish set template, we click start:

Then we get .ipa file. Also I append date to file name to tell different package file, otherwise it will overlap old file.

Link

You can get all codes in https://github.com/gwh111/testcocoappswift
I also add PackageMachine.dmg file for using directly.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值