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.