Files
Menuscript/src/main.swift
2026-02-07 12:17:06 +01:00

354 lines
9.5 KiB
Swift
Executable File

#!/usr/bin/env swift
import AppKit
import Cocoa
class FlippedView: NSView {
override var isFlipped: Bool { true }
}
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private weak var settingsWindow: NSWindow? = nil
func applicationDidFinishLaunching(_ aNotification: Notification) {
// UserDefaults.standard.register(defaults: ["storage": "~/"])
initStatusIcon()
if userStorageURL() == nil {
showSettings()
}
}
private func initStatusIcon() {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let img = statusIcon() {
self.statusItem.button?.image = img
} else {
self.statusItem.button?.title = "" // cant load svg (<10.15)
}
self.statusItem.menu = NSMenu(title: resolvedStorageURL().path)
self.statusItem.menu?.delegate = self
}
private func statusIcon() -> NSImage? {
let img = NSImage(contentsOf: resFile("status", "svg"))
img?.isTemplate = true
img?.size = NSMakeSize(14, 14)
return img
}
func menuDidClose(_ menu: NSMenu) {
if menu == self.statusItem.menu {
self.statusItem.menu = NSMenu(title: menu.title)
self.statusItem.menu?.delegate = self
}
}
/**
Delegate method not used. Here to prevent weird `NSMenu` behavior.
Otherwise, Cmd-Q (Quit) will traverse all submenus (incl. creating unopened).
*/
func menuHasKeyEquivalent(_ menu: NSMenu, for event: NSEvent, target: AutoreleasingUnsafeMutablePointer<AnyObject?>, action: UnsafeMutablePointer<Selector?>) -> Bool {
return false
}
func menuNeedsUpdate(_ menu: NSMenu) {
for entry in listDir(menu.title) {
if entry.action == .Ignore {
continue
}
let itm = menu.addItem(withTitle: entry.title, action: nil, keyEquivalent: "")
itm.representedObject = entry
itm.image = entry.icon()
if entry.isDir || entry.action == .Dynamic {
itm.submenu = NSMenu(title: entry.url.path)
itm.submenu?.delegate = self
} else if entry.action != .Inactive {
itm.action = #selector(menuItemCallback)
}
}
if menu == self.statusItem.menu {
menu.addItem(NSMenuItem.separator())
menu.addItem(withTitle: "Preferences", action: #selector(showSettings), keyEquivalent: ",")
menu.addItem(withTitle: "Quit", action: #selector(NSApp.terminate), keyEquivalent: "q")
}
}
@objc private func menuItemCallback(sender: NSMenuItem) {
let entry = sender.representedObject as! Entry
let cmd = Exec(file: entry.url, env: ["ACTION": "click", "ITEM": entry.title])
if entry.action == .Text {
cmd.editor()
} else {
cmd.run(verbose: entry.action == .Verbose)
}
}
// MARK: - Helper
private func resFile(_ name: String, _ ext: String?) -> URL {
// if run via .app bundle
return Bundle.main.url(forResource: name, withExtension: ext)
// if calling swift directly
?? URL(fileURLWithPath: #file + "/../../res/" + name + (ext == nil ? "" : ("." + ext!)))
}
// MARK: - Manage storage path
private func resolvedStorageURL() -> URL {
userStorageURL() ?? resFile("examples", nil)
}
private func userStorageURL() -> URL? {
UserDefaults.standard.url(forKey: "storage")
}
@objc func selectStoragePath() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.begin {
if $0 == .OK {
self.statusItem.menu?.title = panel.url!.path
// update user defaults
UserDefaults.standard.set(panel.url, forKey: "storage")
// update settings window
let pth = self.settingsWindow?.contentView!.viewWithTag(201) as? NSPathControl
pth?.url = panel.url
}
}
}
@objc func openStoragePath() {
NSWorkspace.shared.open(resolvedStorageURL())
}
// MARK: - Settings View
@objc func showSettings() {
if self.settingsWindow == nil {
let win = NSWindow(
contentRect: NSMakeRect(0, 0, 400, 115),
styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered,
defer: true)
self.settingsWindow = win
win.title = "Preferences"
win.isReleasedWhenClosed = false // because .buffered
win.contentView = initSettingsView()
win.contentMinSize = NSMakeSize(300, win.contentView!.frame.size.height)
win.center()
win.makeKeyAndOrderFront(nil)
}
self.settingsWindow!.orderFrontRegardless()
}
private func initSettingsView() -> NSView {
let pad = 20.0
let view = FlippedView(frame: NSMakeRect(0, 0, 400, 120))
let lbl = NSTextField(frame: NSMakeRect(pad, pad, 400 - 2 * pad, 22))
lbl.isEditable = false
lbl.isBezeled = false
lbl.drawsBackground = false
lbl.stringValue = "Scripts storage path:"
view.addSubview(lbl)
let pth = NSPathControl(frame: NSMakeRect(pad, NSMaxY(lbl.frame), 400 - 2 * pad, 22))
pth.autoresizingMask = [.maxYMargin, .width]
pth.isEditable = true
pth.allowedTypes = ["public.folder"]
pth.pathStyle = .standard
pth.url = resolvedStorageURL()
pth.tag = 201
view.addSubview(pth)
let chg = NSButton(title: "Change", target: self, action: #selector(selectStoragePath))
chg.frame = NSMakeRect(pad, NSMaxY(pth.frame), 90, 40)
chg.autoresizingMask = [.maxXMargin, .maxYMargin]
view.addSubview(chg)
let opn = NSButton(title: "Open", target: self, action: #selector(openStoragePath))
opn.frame = NSMakeRect(NSMaxX(chg.frame), NSMaxY(pth.frame), 90, 40)
// opn.autoresizingMask = [.maxXMargin, .maxYMargin]
view.addSubview(opn)
view.frame.size.height = NSMaxY(pth.frame) + 40 + pad
return view
}
}
// MARK: - Command executor
struct Exec {
let file: URL
var env: [String: String]? = nil
private func prepare() -> Process {
let proc = Process()
proc.environment = self.env
proc.currentDirectoryURL = self.file.deletingLastPathComponent()
proc.executableURL = self.file
return proc
}
private func toPipe() -> Pipe {
let proc = prepare()
let io = Pipe()
proc.standardOutput = io
proc.standardError = io
proc.launch()
return io
}
/// run command (ignoring output)
func run(verbose: Bool = false) {
let proc = prepare()
if verbose {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/open")
proc.arguments = [self.file.path]
}
proc.launch()
}
/// open result in default text editor
func editor() {
let p2 = Process()
p2.launchPath = "/usr/bin/open"
p2.arguments = ["-f"]
p2.standardInput = toPipe()
p2.launch()
}
/// run command and return output
func readData() -> Data {
return toPipe().fileHandleForReading.readDataToEndOfFile()
}
/// run command and return output
func readString() -> String {
return String(data: readData(), encoding: .utf8) ?? ""
}
}
// MARK: - static methods
func listDir(_ path: String) -> [Entry] {
let target = URL(fileURLWithPath: path)
var rv: [Entry] = []
// dynamic menu
if !target.hasDirectoryPath {
Exec(file: target, env: ["ACTION": "list"]).readString().enumerateLines { line, stop in
rv.append(Entry(target, title: line))
}
return rv
}
// else: static menu
for url in (try? FileManager.default.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isDirectoryKey, .isExecutableKey])) ?? [] {
if url.hasDirectoryPath || FileManager.default.isExecutableFile(atPath: url.path) {
rv.append(Entry(url))
}
}
return rv.sorted()
}
// MARK: - A menu item
enum ActionFlag {
case Default
case Text
case Verbose
case Inactive
case Ignore
case Dynamic
static func from(_ filename: inout String) -> Self {
for (key, flag) in [
"[txt]": ActionFlag.Text,
"[verbose]": .Verbose,
"[inactive]": .Inactive,
"[ignore]": .Ignore,
"[dyn]": .Dynamic,
] {
if filename.contains(key) {
filename = filename
.replacingOccurrences(of: key, with: "")
.replacingOccurrences(of: " ", with: " ")
return flag
}
}
return Default
}
}
struct Entry {
let order: Int
let url: URL
let title: String
let action: ActionFlag
let hasDynParent: Bool
var isDir: Bool { url.hasDirectoryPath }
init(_ url: URL, title: String? = nil) {
self.hasDynParent = title != nil
self.url = url
// TODO: remove file extension?
var fname = title ?? url.lastPathComponent
var customOrder = 100
if !hasDynParent {
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
// sort order
if let order = Int(fname[..<idx]) {
customOrder = order
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx
fname = String(fname[idx..<fname.endIndex])
}
}
self.order = customOrder
self.action = ActionFlag.from(&fname)
self.title = fname.trimmingCharacters(in: .whitespaces)
}
func icon() -> NSImage? {
var img: NSImage? = nil
if !hasDynParent {
let basePath = self.isDir ? self.url.appendingPathComponent("icon") : self.url
for ext in ["svg", "png", "jpg", "jpeg", "gif", "ico", "icns"] {
let iconPath = basePath.appendingPathExtension(ext)
if FileManager.default.fileExists(atPath: iconPath.path) {
img = NSImage(contentsOf: iconPath)
break
}
}
if img == nil, self.isDir {
img = NSImage(named: NSImage.folderName)
}
}
if img == nil, action == .Dynamic || hasDynParent {
let cmd = Exec(file: self.url, env: ["ACTION": "icon", "ITEM": hasDynParent ? self.title : ""])
img = NSImage(data: cmd.readData())
}
img?.size = NSMakeSize(16, 16)
return img
}
}
extension Entry: Comparable {
static func < (lhs: Entry, rhs: Entry) -> Bool {
return (lhs.order, lhs.title) < (rhs.order, rhs.title)
}
}
// MARK: - Main Entry
let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
NSApplication.shared.run()
// _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)