From 36bc0c1c8a39759aa963f7108c7baea9004fc17e Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 19 Aug 2024 18:29:46 -0600 Subject: [PATCH] feat: initial working version --- .swift-version | 1 + README.md | 60 ++++++++++- examples/mount-network-shares.sh | 81 ++++++++++++++ run-on-macos-screen-unlock.swift | 175 +++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 .swift-version create mode 100755 examples/mount-network-shares.sh create mode 100755 run-on-macos-screen-unlock.swift diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..f9ce5a9 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.10 diff --git a/README.md b/README.md index a082d6b..279cfef 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ # run-on-macos-screen-unlock -A tiny Swift program to run a command whenever the screen unlocks + +A tiny Swift program to run a command whenever the screen unlocks \ +(I use it for mounting remounting network shares after sleep) + +```sh +run-on-macos-screen-unlock ./examples/mount-network-shares.sh +``` + +# Install + +1. Download + ```sh + curl --fail-with-body -L -O https://github.com/coolaj86/run-on-macos-screen-unlock/releases/download/v1.0.0/run-on-macos-screen-unlock-v1.0.0.tar.gz + ``` +2. Extract + ```sh + tar xvf ./run-on-macos-screen-unlock-v1.0.0.tar.gz + ``` +3. Allow running even though it's unsigned + ```sh + xattr -r -d com.apple.quarantine ./run-on-macos-screen-unlock + ``` +4. Move into your `PATH` + ```sh + mv ./run-on-macos-screen-unlock ~/bin/ + ``` + +# Build from Source + +1. Install XCode Tools \ + (including `git` and `swift`) + ```sh + xcode-select --install + ``` +2. Clone and enter the repo + ```sh + git clone https://github.com/coolaj86/run-on-macos-screen-unlock.git + pushd ./run-on-macos-screen-unlock/ + ``` +3. Build with `swiftc` + ```sh + swiftc ./run-on-macos-screen-unlock.swift + ``` + +# Release + +1. Git tag and push + ```sh + git tag v1.0.x + git push --tags + ``` +2. Create a release \ + +3. Tar and upload + ```sh + tar cvf ./run-on-macos-screen-unlock-v1.0.x.tar ./run-on-macos-screen-unlock + gzip ./run-on-macos-screen-unlock-v1.0.x.tar + open . + ``` diff --git a/examples/mount-network-shares.sh b/examples/mount-network-shares.sh new file mode 100755 index 0000000..d9015e0 --- /dev/null +++ b/examples/mount-network-shares.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -e +set -u + +fn_mount_share() { + a_smb_share="${1:-}" + # ex: /usr/bin/osascript -e 'mount volume "smb://jon@truenas.local/TimeMachineBackups"' + /usr/bin/osascript -e "mount volume \"${a_smb_share}\"" +} + +fn_version() { + echo "mount-macos-network-shares v1.0.0 (2024-08-19)" + echo "Copyright AJ ONeal (MPL-2.0)" +} + +fn_help() { + echo "" + echo "USAGE" + echo " mount-macos-network-shares [path-to-config]" + echo "" + echo "OPTIONS" + echo " --help - print this message" + echo " -V,--version - print the version" + echo "" + echo "CONFIG" + echo " Default config file:" + echo " ~/.config/macos-network-shares/urls.conf" + echo "" + echo " Example config file contents:" + echo " smb://puter:secret@truenas.local/TimeMachineBackups" + echo " smb://wifu@192.168.1.101/Family Photos" + echo "" +} + +fn_mount_shares() { + b_urls_file="${1}" + while IFS= read -r b_share_url; do + fn_mount_share "${b_share_url}" + done < "${b_urls_file}" +} + +main() { + if ! test -f ~/.config/macos-network-shares/urls.conf; then + mkdir -p ~/.config/macos-network-shares/ || true + chmod 0700 ~/.config/macos-network-shares || true + touch ~/.config/macos-network-shares/urls.conf || true + chmod 0600 ~/.config/macos-network-shares/urls.conf || true + echo "#smb://user:pass@truenas.local/TimeMachineBackups" >> ~/.config/macos-network-shares/urls.conf || true + fi + + b_urls_file="${1-$HOME/.config/macos-network-shares/urls.conf}" + case "${b_urls_file}" in + --version | -V | version) + fn_version + exit 0 + ;; + --help | help) + fn_help + exit 0 + ;; + *) ;; + esac + + if ! test -e "${b_urls_file}" && grep -q -v -E '^\s*(#.*)?$' "${b_urls_file}"; then + { + echo "" + echo "ERROR" + echo " url list '${b_urls_file}' is empty or does not exist" + echo "" + fn_help + } >&2 + fi + + { + echo "Network URLs List: ${b_urls_file}" + } >&2 + + fn_mount_shares "${b_urls_file}" +} + +main "$@" diff --git a/run-on-macos-screen-unlock.swift b/run-on-macos-screen-unlock.swift new file mode 100755 index 0000000..980b35c --- /dev/null +++ b/run-on-macos-screen-unlock.swift @@ -0,0 +1,175 @@ +import Foundation + +let name = (CommandLine.arguments[0] as NSString).lastPathComponent +let version = "1.0.0" +let build = "2024-08-19-001" + +let versionMessage = """ +\(name) \(version) (\(build)) + +""" + +let copyrightMessage = """ +Copyright 2024 AJ ONeal + +""" + +let helpMessage = """ +Runs a user-specified command whenever the screen is unlocked by +listening for the "com.apple.screenIsUnlocked" event, using /usr/bin/command -v +to find the program in the user's PATH (or the explicit path given), and then +runs it with /usr/bin/command, which can run aliases and shell functions also. + +USAGE + \(name) [OPTIONS] [--] [command-arguments] + +OPTIONS + --version, -V, version + Display the version information and exit. + --help, help + Display this help and exit. + +DESCRIPTION + \(name) is a simple command-line tool that demonstrates how to handle + version and help flags in a Swift program following POSIX conventions. + +""" + +signal(SIGINT) { _ in + printForHuman("received ctrl+c, exiting...\n") + exit(0) +} + +enum ScriptError: Error { + case fileNotFound +} + +func printForHuman(_ message: String) { + if let data = message.data(using: .utf8) { + FileHandle.standardError.write(data) + } +} + +func getCommandPath(_ command: String) -> String? { + let commandv = Process() + commandv.launchPath = "/usr/bin/command" + commandv.arguments = ["-v", command] + + let pipe = Pipe() + commandv.standardOutput = pipe + commandv.standardError = FileHandle.standardError + + try! commandv.run() + commandv.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let scriptPath = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + else { + return nil + } + + if commandv.terminationStatus != 0, scriptPath.isEmpty { + return nil + } + + return scriptPath +} + +class ScreenLockObserver { + var commandPath: String + var commandArgs: ArraySlice + + init(_ commandArgs: ArraySlice) { + self.commandPath = commandArgs.first! + self.commandArgs = commandArgs + + let dnc = DistributedNotificationCenter.default() + + _ = dnc.addObserver(forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main) { _ in + NSLog("notification: com.apple.screenIsLocked") + } + + NSLog("Waiting for 'com.apple.screenIsUnlocked' to run \(self.commandArgs)") + _ = dnc.addObserver(forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main) { _ in + NSLog("notification: com.apple.screenIsUnlocked") + self.runOnUnlock() + } + } + + private func runOnUnlock() { + let task = Process() + task.launchPath = "/usr/bin/command" + task.arguments = Array(commandArgs) + task.standardOutput = FileHandle.standardOutput + task.standardError = FileHandle.standardError + + do { + try task.run() + } catch { + printForHuman("Failed to run \(self.commandPath): \(error.localizedDescription)\n") + if let nsError = error as NSError? { + printForHuman("Error details: \(nsError)\n") + } + exit(1) + } + + task.waitUntilExit() + } +} + +@discardableResult +func removeItem(_ array: inout ArraySlice, _ item: String) -> Bool { + if let index = array.firstIndex(of: item) { + array.remove(at: index) + return true + } + return false +} + +func processArgs(_ args: inout ArraySlice) -> ArraySlice { + var childArgs: ArraySlice = [] + if let delimiterIndex = args.firstIndex(of: "--") { + let childArgsIndex = delimiterIndex + 1 + childArgs = args[childArgsIndex...] + args.removeSubrange(delimiterIndex...) + } + if removeItem(&args, "--help") || removeItem(&args, "help") { + printForHuman(versionMessage) + printForHuman("\n") + printForHuman(helpMessage) + printForHuman("\n") + printForHuman(copyrightMessage) + exit(0) + } + if removeItem(&args, "--version") || removeItem(&args, "-V") || removeItem(&args, "version") { + printForHuman(versionMessage) + printForHuman(copyrightMessage) + exit(0) + } + + childArgs = args + childArgs + guard childArgs.count > 0 else { + printForHuman(versionMessage) + printForHuman("\n") + printForHuman(helpMessage) + printForHuman("\n") + printForHuman(copyrightMessage) + exit(1) + } + + let commandName = childArgs.first! + guard let commandPath = getCommandPath(commandName) else { + printForHuman("ERROR:\n \(commandName) not found in PATH\n") + exit(1) + } + + childArgs[childArgs.startIndex] = commandPath + return childArgs +} + +var args = CommandLine.arguments[1...] +let commandArgs = processArgs(&args) +_ = ScreenLockObserver(commandArgs) + +RunLoop.main.run()