Search

Putting a password on zip files with a simple dialog

Compressing files is easy enough on macOS. You select files, right-click on them and select "Compress ... items". macOS creates a zip file that either has the name of the item that was compressed, or is named "Archive.zip" if more than one item was selected.

When you want to add a password to the zip, just to make sure that the file cannot be opened by people who should not access it, it is a different story. You must either use a third party utility, or the Terminal.

Terminal has everything you need, but even people who often use the command line can forget the syntax, the options and a number of other things. The ideal would be to be able to leverage the command line with a minimum set of parameters, directly from a simple graphical user interface.

And that's where AppleScript shines...

I thought about putting something like this together a while ago, but it's only a recent client who always sends me password locked zipped files that really made me want to scratch this itch. Basically I want something that behaves almost exactly as when the Finder does it:

1) select the files in Finder
2) launch the compress command
3) the zipped file is created at the shortest common directory
4) Finder opens a window for that directory with the zipped file selected

What we need to add is

2b) propose a random password to lock the opening of the archive

That seems simple enough when you write it like this and in fact there are a number of solutions posted online that make use of AppleScript and the command line, but I could not find one that replicated the Finder's behavior. The closest (and shortest) was an AppleScript that created one zip file for each selected file, but not respecting 3) and 4) above, I was not satisfied with it.

The problem here is in fact finding the shortest common directory. It would probably be a piece of AppleScript cake for experienced programmers, but I'm not there yet, if I'll ever be...

I tested a number of approaches. The first one being, sort the files by length of path (number of "container"), find the shortest path, use that as a base to find the shortest common path. Then I realized that 2 files could have a similarly short path but have little in common, so I'd have to choose one arbitrarily to use as the base for the shortest common path. Then it occurred to me that even if a file had a shorter path than another, it did not mean that the shortest path included the longest one.

So, I was running in all sorts of directions and wrote a lot of really fun code (including a nice recursive function) when I decided to bite the bullet and just take any first file that Finder would hand me and use it to start the comparison.

When I restarted coding pretty much from scratch, I ended up at a point where I had all the path items for a give file handled as strings: "Mabinogion" "Users" "suzume" "Document", etc. and I had to compare all the nth items of each file simultaneously to see whether they were all equal (in which case, that folder was a common folder) or if one or more were not identical (in which case the considered folders were not common and I could stop the search).

Once I had that common folder, I had to "subtract" it from the path of each considered file to obtain a relative path that I could use in my "behind the AppleScript scene" zip command. I have to thank Shane Stanley for the hint he gave me, and I really wish there was a logical way in AppleScript to handle paths and file system items that does not involve string manipulation...

Anyway, the following code took me about 3 days to complete, and even though I've tested it fairly well, there are still possibly a few issues with it. The password is the first 12 characters of the md5 hash of "date", which changes every second so it should be good enough as a relatively strong password. It is also generated every time you use the script.


use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

# We initialize a number of variables that will be used throughout the script, including the password

set savedTextDelimiters to AppleScript's text item delimiters
set AppleScript's text item delimiters to {":"}
set selectionList to {}
set commonPath to {}
set zipcommand to ""
set zippassword to (do shell script "date | md5 | head -c12")


tell application "Finder"
set mySelection to selection
# If no files are selected the script simply stops.
if length of mySelection = 0 then
display alert "Select files to compress"
return
# If just one file is selected, the process is straightforward.
else if length of mySelection = 1 then
set fileName to name of item 1 of mySelection
set targetfile to quoted form of (fileName)
set fileContainer to (POSIX path of (container of item 1 of mySelection as alias))
set commonPath to quoted form of fileContainer
set zipFile to quoted form of (fileContainer & fileName & ".zip")
# If more than one file is selected, we have to first split the file paths into their Finder elements (a list of folders that starts at the disk and ends at the selection, which can be a folder or a file).
else
try
repeat with myItem in mySelection
copy text items of (contents of myItem as text) to end of selectionList
end repeat
# We now have a list of paths that are stored as lists of Finder elements and we need to compare all the Finder elements one by one and only keep the ones that are common to all the files.
repeat with i from 1 to length of item 1 of selectionList
set referenceItem to item i of item 1 of selectionList
repeat with selectedFile in selectionList
if (item i of contents of selectedFile is not equal to referenceItem) then
set sameItem to false
exit repeat
else
set sameItem to true
end if
end repeat
if sameItem is true then
copy referenceItem to end of commonPath
set sameItem to false
else
exit repeat
end if
end repeat
# We now have the common path, which leads to a folder that all the files have in common. When we'll zip the files, we'll first move to that folder and will call all the files to be zipped by their path relative to that folder, so now we need to subtract the common path from the path of all the selected files.
set commonPath to POSIX path of ((commonPath as text) & ":" as alias)
repeat with myItem in mySelection
set contents of myItem to quoted form of ("." & text (length of commonPath) thru -1 of (POSIX path of (contents of myItem as alias)))
end repeat
# Ok, all the file paths are now ready. We now need to prepare the zip command and make sure that all the paths that we'll use are in "quoted form" since we'll be using the "do shell script" command and "quoted form of" allows us to manipulate paths that include spaces.
set zipFile to quoted form of (commonPath & "Archive.zip")
set AppleScript's text item delimiters to {" "}
set targetfile to mySelection as text
set commonPath to quoted form of commonPath
end try
end if
# Now we let the user validate the password that was generated at the beginning of the script, or eventually change it to something she prefers, we generate the command that will be launched by "do shell script", we launch the command and we ask Finder the open a window on the common folder where the archive was created and to select it.
try
set zippassword to quoted form of (text returned of (display dialog "Password:" default answer zippassword buttons {"OK", "Cancel"} default button "OK" cancel button "Cancel"))
set zipcommand to "cd " & commonPath & "; " & "zip -r " & zipFile & " " & targetfile & " --password " & zippassword # & " ; open " & commonPath
do shell script zipcommand
set AppleScript's text item delimiters to {""}
set archiveFile to (((items 2 thru ((length of zipFile) - 1)) of zipFile) as text) as POSIX file
select archiveFile
end try
end tell

# Just to make sure, but there should be no reason to worry, we reinitialize all the important information

set AppleScript's text item delimiters to savedTextDelimiters
set selectionList to {}
set commonPath to {}
set zipcommand to ""
set zippassword to ""


# Et voilĂ  !


If you save this script as an application, you can call it from Spotlight every time you need to password-compress files. You can also put it into an Automator service and assign a shortcut to it. Or put it into the dock and click on it when files are selected in the Finder. Whatever the method is, the result will be the same: the selected files in Finder will be compressed and locked with the password you provided and Finder will open a window where the compressed file is selected. You can then copy it, paste it into a mail, or copy it into a different folder, etc.

In any case, don't forget to store the password somewhere, either to send it to your client so that the archive can be opened there, or to keep it somewhere on your machine if the archive is for local use. Just like with any password locked file, don't erase the original file before you are sure the password is safely stored somewhere, otherwise you would not be able to access the archived files anymore...

Ok, that's this end for 2017. I'm glad I resumed writing this year. And I'm hoping you've found some use in the things I wrote.

Automation + Scheduling file and application opening...

How do you automate the scheduling of a script/application launching (or any kind of file opening)?

macOS provides at least two solutions when it comes to scheduling file opening and application launching. One is the "open file alert" thing in Calendar and the other is launchd.

If you don't need to automate the creation of open file alerts and have no problem creating them by hand, you'll do just fine with Calendar. There are tons of articles on the web that describe how to do it. But that is not the subject of this article...

The problem with Calendar

As I discovered over the course of the last weekend when I (at long last) decided to automate my invoicing by programmatically creating invoice launch triggers for each job that I created with my usual script, it is not possible to script Calendar to create proper open file alerts.

Since Calendar became a sandboxed application (which is a good thing), it is not possible anymore to specify the path to the file you want to open with AppleScript. Specifically, the problem is the following: when you check Calendar's dictionary in your script editor, you can see that an open file alert object has three properties: a filepath, a trigger date and a trigger interval. You can set the last two with Applescript, but setting filepath is not allowed. You just can't. Period. And it's not even worth wasting time trying.

In fact, Calendar does not even return the filepath of an open file alert that you'd have successfully created manually. The only thing that's returned is an empty string. The result is that you can only partially script the creation of the open file alert, without the most important part, which is specifying the file that you want to open at the specified time. So you're back to manually completing the creation of the alert, which defeats the purpose of automating this task in the first place.

The solution? launchd!

Calendar being out of the picture, the only solution that remains is launchd.

According to people who know, launchd is extremely powerful, and I'm only going to very slightly scratch the surface of what it can do here, which is still sufficient for what we want to accomplish: create a thing that launches the application/script of your choice at the time you specified and using that creation process in a script.

launchd is not just a process launcher, it is the process launcher on macOS. In fact, launchd is the very first process that is started when you launch your Mac. Once it has launched, it launches, or loads for later launching, all the processes that Mac needs to run properly. launchd also has a number of other roles that go beyond what it relevant here and what I can understand of my machine's internals...

Using launchd to manually schedule processes is very different from doing so in Calendar. launchd only works from the command line, its settings require manipulating XML based plist files, and such files require knowledge of a number of terms specific to the launchd format. For our purpose launchd requires what is called a "launch agent".

Launch agents define the actions to take and their timing in XML Property List files (.plist). User created agents are located in ~/Library/LaunchAgents and are "loaded" by launchctl which is the only way the user can interact with the agents and with launchd.

To automate the creation of the equivalent of Calendar's "open file alert", we'll need to create an agent that would look like:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
 <key>Label</key>
 <string>org.mac4translators.launchd.test</string>
 <key>ProgramArguments</key>
 <array>
  <string>/usr/bin/osascript</string>
  <string>~/MyScripts/mypresents.scrpt</string>
 </array>
 <key>StartCalendarInterval</key>
 <dict>
  <key>Day</key>
  <integer>25</integer>
  <key>Hour</key>
  <integer>00</integer>
  <key>Minute</key>
  <integer>00</integer>
  <key>Month</key>
  <string>December</string>
 </dict>
</dict>
</plist>

Then we need to load it, and unload it when it has accomplished its mission.

Using launchd from AppleScript is not very different from scripting Calendar. It only involves a different set of objects, but not different concepts.

The following code, adapted from what can be found on the web already, creates the agent above and puts it in the location from where we will be able to load it in launchd. The comments should be sufficient for you to understand what is going on and to create your own launch agents. The highlighted parts are the parts where we'll need to adapt contents so as to truly automate the creation of other agents.


use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

# Create the file path where the launchd agent plist will be created. As written in the man page, it is a convention that the name of the file should be Label.plist. See below for what "Label" is.

set the launch_agent_path to "~/Library/LaunchAgents/org.mac4translators.launchd.test.plist" #

# System Events and it's plist suite are the tools we need to create the plist itself.

tell application "System Events"

 # Create an empty property list.

 set the launch_agent to make new property list item with properties {kind:record}

 # Create a new property list file using the empty launch_agent as contents and the launch_agent_path as location.

 set this_launch_agent to make new property list file with properties {contents:launch_agent, name:launch_agent_path}

 # Now we work inside the property list, that starts empty.

 tell property list items of this_launch_agent
  
  # Add new property list items consecutively, from the end of the file. The first item is "Label", which should be a unique identifier: you can add a date, or a job name to make it truly unique. As written above, the "Label" value should be the name of the file before the .plist extension.

  make new property list item at its end with properties {kind:string, name:"Label", value:"org.mac4translators.launchd.test"} #
  
  # The second item is the arguments to the command that will launch your program. The command is "exec", so in the present case, think of launchd as really launching "exec /usr/bin/osascript ~/MyScripts/mypresents.scrpt", which ends up running the mypresents.scrpt script (check "man osascript" for more information on running your scripts from the command line).

  make new property list item at its end with properties {kind:record, name:"ProgramArguments", value:{"/usr/bin/osascript", "~/MyScripts/mypresents.scrpt"}} #
  
  # The third item is the date/time you want the thing launched. Notice that this is the "Start Calendar Interval" value. It is not a unique date which is the reason why the "Year" argument is not supported. Your agent will thus run at least once a year, unless it is unloaded after having accomplished its mission. "Month" and "Day" are put between vertical bars to avoid conflict with AppleScript because AppleScript thinks they are AS terminology, but here they're only XML values in the plist file.

  make new property list item at its end with properties {kind:list, name:"StartCalendarInterval", value:{|Month|:"December", |Day|:25, Hour:0, Minute:0}} #
 end tell
end tell


When you run this code, you should get an XML plist that looks like the one above.

So, we now have a way to programmatically create our launch agent. But to create a new one we'll have to edit the above code and change the highlighted parts, which is still way more complicated than editing a plain XML plist file. That's where real automation of the process comes into play.

What we need to feed the script is the following:

  1. a unique "label" for the agent, used in above
  2. the thing to launch (as we'd launch it from the command line), used in above
  3. the trigger date/interval,  used in above

So, now the script becomes:


use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions

# We'll need to work with specific text items delimiters later so let's store the defaults in a safe place.

set savedTextDelimiters to AppleScript's text item delimiters

# We need 3 items to create the Launch Agent:
# 1) A unique label. You can use a job name that you fetch from another script.

set agent_label to text returned of (display dialog "Launch Agent Label?" default answer (short user name of (system info)) & "." & (time of (current date) as text))
set the launch_agent_path to "~/Library/LaunchAgents/" & agent_label & ".plist"

# 2) The command to launch your application/script, as you'd type it in Terminal. Anything that goes on the command line will work. For ex: open -a "Microsoft Word" ~/Documents/myInvoiceTemplate.docx

set agent_command to text returned of (display dialog "Launch Agent Command?" default answer "osascript ~/MyAppleScripts/myInvoicingScript.scrpt")
set AppleScript's text item delimiters to {" "}
set agent_parameters to get text items of agent_command

# 3) The trigger date. "Year" is not something that you can set, so just leave it out.

set agent_trigger_date to text returned of (display dialog "Launch Agent Trigger Date?" default answer "mm/dd/hr/mn")
set AppleScript's text item delimiters to {"/"}
set agent_trigger_date_fields to get text items of agent_trigger_date

# And we return the text delimiters to their default value

set AppleScript's text item delimiters to savedTextDelimiters

# Now we ask System Events and it's plist suite to create the plist itself and to fill it with the data we just fed it. The code being the same as above, comments are left out.

tell application "System Events"
 set the launch_agent to make new property list item with properties {kind:record}
 set this_launch_agent to make new property list file with properties {contents:launch_agent, name:launch_agent_path}
 tell property list items of this_launch_agent
  make new property list item at its end with properties {kind:string, name:"Label", value:agent_label}
  make new property list item at its end with properties {kind:record, name:"ProgramArguments", value:agent_parameters}
  make new property list item at its end with properties {kind:list, name:"StartCalendarInterval", value:{|Month|:item 1 of agent_trigger_date_fields, |Day|:item 2 of agent_trigger_date_fields, Hour:item 3 of agent_trigger_date_fields, Minute:item 4 of agent_trigger_date_fields}}
 end tell
end tell

# We have created the launch agent where it can now be loaded into the system to be launched at the date we specified. We just need to make sure everything is fine and then load it with launchctl:

# Open the agent with TextEdit, or change the command to open with your editor of choice.

do shell script "open -e " & launch_agent_path

# Ask whether the agent is fine, and if yes, load it.

tell application "System Events"
 tell (first process whose frontmost is true)
  set reallyLaunch to button returned of (display alert "Are you fine with this Launch Agent?")
 end tell
end tell

if reallyLaunch is "OK" then
 set load_agent to "launchctl load " & launch_agent_path
 do shell script load_agent
end if


Et voilĂ ! Now we eventually need to separately find a way to regularly check the agents that we created and unload/remove them from ~/Library/LaunchAgents/ after their mission is accomplished... You can for example match the name of the agent to the name of your job and use do shell script to unload and remove the agent once the invoice has been created.

The resulting system is not exactly as elegant as just automating the creation of an "open file alert" in Calendar, but since that is not possible anymore, we don't have much choice.

Update (12/11):

It appears that I had not fully understood the Program key that I had mentioned in the first version of this article. I've removed references to it, but you can still use it in place of  ProgramArguments if your command runs on its own and does not take arguments. The command must be in your path to be discovered by which, but if you've understood the code so far you should be able to change it so that it fits your configuration.

The plist would require the following key:

<key>Program</key>
<string>/Users/[your account]/bin/[your command]</string>

And you'd need the following code (first listing) to generate it:

make new property list item at its end with properties {kind:string, name:"Program", value:"/Users/[your account]/bin/[your command]"} #

Or this code in the second listing:

set agent_command to text returned of (display dialog "Launch Agent Command?" default answer "[your command]")
set AppleScript's text item delimiters to {" "}
set agent_parameters to get text items of agent_command

set agent_program to do shell script "which " & item 1 of agent_parameters
...
make new property list item at its end with properties {kind:string, name:"Program", value:agent_program}

References:

launchd was released in 2005 and included in Tiger that same year. If you want to know more about it, read the following links (pretty much all the places from where I gathered information to write this article):

launchd Wikipedia page:

launchd plist format (man page)

launchd description (man page)

launchctl description (man page)

You can also access the man pages on your local system (man launchd, man launchctl, man launch.plist)

Apple Developer documentation about launchd:

"Daemons and Agents", Apple Technical Note TN2083

Detailed overview of launchd by Jonathan Levin, who  wrote "the" book on OSX internals:

The chapter from that book that covers launchd (which also happens to be in free access):

A very thorough and very well designed introduction to launchd, by soma-zone, the creators of LaunchControl, "the only full featured launchd GUI for macOS", it is not applescriptable.

An online tool to easily create launchd agents (and check their syntax before you automate all that with AppleScript):

"How to Use launchd to Run Scripts on Schedule in macOS"

Some AppleScript code to write property lists:

A property list applescript library (not really needed for our purposes, but just so that you know):

Lingon is a FOSS but inactive utility (development has continued on the paid version) that helps you create, visualize and manage the launch agents that run on your machine, it is not applescriptable:

Discussions:

The discussions that triggered the writing of this of this article...

AppleScript official list

AppleScript unofficial list

Script Debugger user forum