8 Commits
v1.0.1 ... main

Author SHA1 Message Date
relikd
c41c92e637 chore: bump version 2026-02-07 12:41:14 +01:00
relikd
a7f9b3d48c doc: add example menu item icon 2026-02-07 12:35:29 +01:00
relikd
6867488a4a feat: ignore items which start with underscore 2026-02-07 12:25:08 +01:00
relikd
284eb1aa5c feat: icons for menu items 2026-02-07 12:17:06 +01:00
relikd
d514bfb610 feat: dynamic menus 2026-02-07 12:13:53 +01:00
relikd
8053110349 feat: support for .icns icons 2026-02-07 11:54:26 +01:00
relikd
e2743903e3 feat: new flags inactive and ignore 2026-02-03 19:25:06 +01:00
relikd
dcfe16cb9b ref: Exec, ActionFlag, Entry 2026-02-03 19:19:54 +01:00
7 changed files with 232 additions and 94 deletions

View File

@@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] 2026-02-07
Added:
- Dynamic menus (`[dyn]` flag)
- Modifier flags `[ignore]` and `[inactive]`
- Icons for menu items (additional to folder icons)
- `.icns` icons
- Hide menu items which start with underscore
## [1.0.1] 2026-01-28 ## [1.0.1] 2026-01-28
Fixed: Fixed:
- Crash on macOS 10.13 if path contains space character - Crash on macOS 10.13 if path contains space character
@@ -15,5 +24,6 @@ Fixed:
Initial release Initial release
[1.1.0]: https://github.com/relikd/Menuscript/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/relikd/Menuscript/compare/v1.0.0...v1.0.1 [1.0.1]: https://github.com/relikd/Menuscript/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/relikd/Menuscript/compare/1eca425c5f453bc0ed47f780d491656549d3ab53...v1.0.0 [1.0.0]: https://github.com/relikd/Menuscript/compare/1eca425c5f453bc0ed47f780d491656549d3ab53...v1.0.0

View File

@@ -12,7 +12,7 @@ A menu bar script executor.
Menuscript adds a status bar menu to call custom (user defined) scripts. Menuscript adds a status bar menu to call custom (user defined) scripts.
The app reads the content of a directory and adds all executable files to the status menu. The app reads the content of a directory and adds all executable files to the status menu.
The screenshot above represents the content of the [res/examples](res/examples/) directory. See [res/examples](res/examples/) directory.
## Usage ## Usage
@@ -36,13 +36,18 @@ Apart from that, there is no limitation on the script language.
You can use Bash, Python, Swift, Ruby, whatever. You can use Bash, Python, Swift, Ruby, whatever.
And of course, you can always write a script wrapper to call something else. And of course, you can always write a script wrapper to call something else.
=> If you can call the script with `open` (e.g., `open myscript`), it will work in the status menu too. => If you can run the script with `open` (e.g., `open myscript`), it will work in the status menu too.
### Configuration ### Configuration
There are a few ways to modify the menu structure: There are a few ways to modify the menu structure:
#### Menu Icon
A subdirectory can have a custom icon if the folder contains an image file named `icon.X` (where `X` is one of: `svg`, `png`, `jpg`, `jpeg`, `gif`, `ico`, `icns`).
For menu items, the icon file should be named exactly like the script file plus one of the icon extensions (e.g., `cmd.sh` -> `cmd.sh.png`).
#### Sort Order #### Sort Order
By default, menu items are sorted in alphabetic order (case-insensitive). By default, menu items are sorted in alphabetic order (case-insensitive).
@@ -60,8 +65,33 @@ Flags are defined by adding a text snippet to the filename.
These constant strings are defined: These constant strings are defined:
- __[txt]__: Execute the script and dump all output in a new `TextEdit` window (useful for reports or log files, etc.) - __[txt]__: Execute the script and dump all output in a new `TextEdit` window (useful for reports or log files, etc.)
- __[verbose]__: Usually, script files are executed in the background. With this flag, a new `Terminal` window will open and show the activley running script (useful for continuous output like `top` or `netstat -w`, etc.) - __[verbose]__: Usually, script files are executed in the background.
With this flag, a new `Terminal` window will open and show the activley running script (useful for continuous output like `top` or `netstat -w`, etc.)
- __[inactive]__: Make menu item non-clickable (will appear greyed out)
- __[ignore]__: Do not show menu item (no menu entry)
- __[dyn]__: If set, the script file is responsible to generate its menu items.
See [Dynamic Menus](#dynamic-menus).
#### Menu Icon #### Dynamic Menus
A subdirectory can have a custom icon if the folder contains an image file named `icon.X` (where `X` is one of: `svg`, `png`, `jpg`, `jpeg`, `gif`, `ico`). A single script file can generate a whole menu with multiple menu items ("[dyn]" flag).
As with other scripts, the filename will be used as menu title, in this case a menu folder.
The dynamic script is called with environment variables `ACTION` and `ITEM`.
Action can be one of: `list` (generate menu), `click` (item clicked), or `icon` (should return image data).
- `ACTION=list` is called without `ITEM` env and should return a list of menu items to display.
Just print each menu item on a separate line.
Item titles can use the same [modifier flags](#modifier-flags) as normal script files.
But contrary to normal script files, sort order is defined by the dynamic script (you should not use numbers).
- `ACTION=click` always provides an `ITEM` with the currently clicked menu item title (after removing modifier flags).
Your script should perform the intended action of the menu item.
- `ACTION=icon` may return an icon for any menu item (or return nothing to omit the icon).
`ITEM` is empty for the folder menu icon and set to the menu title if called for an individual item (same as `click` action).
You should return image data, e.g., by printing the content of an image file or returning SVG code.
You could use the icon to display an online status for example.
Keep in mind, that the menu is generated only once after the status menu opens.
Limitations: dynamic menus cannot have dynamic submenus.
Also, dynamic items cannot use the verbose flag (for now).

31
res/examples/Dyn [dyn] Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
status() {
echo '<svg viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="' "$1" '"/></svg>'
}
case $ACTION in
list)
echo "Item 1 [verbose]"
echo "Item 2 (Text Editor) [txt]"
echo "Item 3 [ignore]"
echo "Item 4 [txt]"
echo "🔴 Status"
echo "Time: $(date) [inactive]"
;;
click)
case $ITEM in
"Item 1") echo "First item clicked";;
"Item 2 (Text Editor)") echo "Second item clicked";;
*) echo "else: click >$ITEM<";;
esac;;
icon)
case $ITEM in
"") cat _icons/draw.png;; # Main menu icon
"Item 1") cat _icons/draw.png;; # relative to root scripts dir
*Status) status green;;
esac;;
*)
# [verbose] is missing the env variables
echo "unsupported action $ACTION, item: $ITEM"
esac

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 8 8">
<rect y="3.5" width="8" height="1" fill="violet"/>
</svg>

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.1</string> <string>1.1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>3</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.13</string> <string>10.13</string>
<key>LSBackgroundOnly</key> <key>LSBackgroundOnly</key>

View File

@@ -52,15 +52,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
} }
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
for entry in Entry.listDir(menu.title) { for entry in listDir(menu.title) {
if entry.action == .Ignore {
continue
}
let itm = menu.addItem(withTitle: entry.title, action: nil, keyEquivalent: "") let itm = menu.addItem(withTitle: entry.title, action: nil, keyEquivalent: "")
itm.representedObject = entry itm.representedObject = entry
itm.image = entry.icon() itm.image = entry.icon()
if entry.isDir { if entry.isDir || entry.action == .Dynamic {
itm.submenu = NSMenu(title: entry.url.path) itm.submenu = NSMenu(title: entry.url.path)
itm.submenu?.delegate = self itm.submenu?.delegate = self
} else { } else if entry.action != .Inactive {
itm.action = #selector(menuItemCallback) itm.action = #selector(menuItemCallback)
} }
} }
@@ -73,7 +76,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
} }
@objc private func menuItemCallback(sender: NSMenuItem) { @objc private func menuItemCallback(sender: NSMenuItem) {
(sender.representedObject as! Entry).run() 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 // MARK: - Helper
@@ -170,112 +179,166 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
} }
} }
// MARK: - A menu item
struct Entry { // MARK: - Command executor
enum Flags {
case Verbose struct Exec {
case Text 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),
!url.lastPathComponent.hasPrefix("_")
{
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 order: Int
let title: String
let flags: [Flags]
let url: URL let url: URL
let title: String
let action: ActionFlag
let hasDynParent: Bool
var isDir: Bool { url.hasDirectoryPath } var isDir: Bool { url.hasDirectoryPath }
init(_ url: URL) { init(_ url: URL, title: String? = nil) {
self.hasDynParent = title != nil
self.url = url self.url = url
// TODO: remove file extension? // TODO: remove file extension?
var fname = url.lastPathComponent var fname = title ?? url.lastPathComponent
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex var customOrder = 100
// sort order if !hasDynParent {
if let order = Int(fname[..<idx]) { var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
self.order = order // sort order
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx if let order = Int(fname[..<idx]) {
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx customOrder = order
fname = String(fname[idx..<fname.endIndex]) idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx
} else { idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx
self.order = 100 fname = String(fname[idx..<fname.endIndex])
}
// flags
var flags: [Flags] = []
for (key, flag) in ["verbose": Flags.Verbose, "txt": .Text] {
if fname.contains("[" + key + "]") {
fname = fname.replacingOccurrences(of: "[" + key + "]", with: "")
.replacingOccurrences(of: " ", with: " ")
flags.append(flag)
} }
} }
self.flags = flags self.order = customOrder
self.action = ActionFlag.from(&fname)
self.title = fname.trimmingCharacters(in: .whitespaces) self.title = fname.trimmingCharacters(in: .whitespaces)
} }
static func listDir(_ path: String) -> [Entry] {
var rv: [Entry] = []
for url
in (try? FileManager.default.contentsOfDirectory(
at: URL(fileURLWithPath: path),
includingPropertiesForKeys: [.isDirectoryKey, .isExecutableKey])) ?? []
{
if url.hasDirectoryPath || FileManager.default.isExecutableFile(atPath: url.path) {
rv.append(Entry(url))
}
}
return rv.sorted()
}
func icon() -> NSImage? { func icon() -> NSImage? {
guard isDir else {
return nil
}
var img: NSImage? = nil var img: NSImage? = nil
for ext in ["svg", "png", "jpg", "jpeg", "gif", "ico"] { if !hasDynParent {
let iconPath = self.url.appendingPathComponent("icon." + ext) let basePath = self.isDir ? self.url.appendingPathComponent("icon") : self.url
if FileManager.default.fileExists(atPath: iconPath.path) { for ext in ["svg", "png", "jpg", "jpeg", "gif", "ico", "icns"] {
img = NSImage(contentsOf: iconPath) let iconPath = basePath.appendingPathExtension(ext)
break 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 { if img == nil, action == .Dynamic || hasDynParent {
img = NSImage(named: NSImage.folderName) let cmd = Exec(file: self.url, env: ["ACTION": "icon", "ITEM": hasDynParent ? self.title : ""])
img = NSImage(data: cmd.readData())
} }
img?.size = NSMakeSize(16, 16) img?.size = NSMakeSize(16, 16)
return img return img
} }
func run() {
let proc = Process()
proc.currentDirectoryURL = self.url.deletingLastPathComponent()
// proc.environment = ["HOME": "$HOME"]
if self.flags.contains(.Verbose) {
proc.launchPath = "/usr/bin/open"
proc.arguments = [self.url.path]
} else {
proc.executableURL = self.url
}
if self.flags.contains(.Text) {
// open result in default text editor
let io = Pipe()
proc.standardOutput = io
proc.standardError = io
proc.launch()
let p2 = Process()
p2.launchPath = "/usr/bin/open"
p2.arguments = ["-f"]
p2.standardInput = io
p2.launch()
// let data = io.fileHandleForReading.readDataToEndOfFile()
// let output = String(data: data, encoding: .utf8) ?? ""
} else {
proc.launch()
}
}
} }
extension Entry: Comparable { extension Entry: Comparable {