14 Commits
v1.0.0 ... 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
relikd
8242a64a5d feat: changelog 2026-01-28 12:58:59 +01:00
relikd
355cf0ded1 feat: svg status icon 2026-01-28 12:48:07 +01:00
relikd
fa5551f272 fix: svg transparent fill color 2026-01-28 12:45:33 +01:00
relikd
b6eaa8d3c4 ref: move files to dedicated res folder 2026-01-28 12:44:43 +01:00
relikd
1af81e5b4a fix: file path on macOS 10.13 2026-01-28 12:43:09 +01:00
relikd
05714ef69e fix: remove unused SwiftUI import 2026-01-27 23:53:34 +01:00
25 changed files with 283 additions and 113 deletions

29
CHANGELOG.md Normal file
View File

@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
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).
## [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
Fixed:
- Crash on macOS 10.13 if path contains space character
- Larger status icon glyph
## [1.0.0] 2026-01-27
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.0]: https://github.com/relikd/Menuscript/compare/1eca425c5f453bc0ed47f780d491656549d3ab53...v1.0.0

View File

@@ -11,7 +11,7 @@ HAS_SIGN_IDENTITY=$(shell security find-identity -v -p codesigning | grep -q "Ap
Menuscript.app: SDK_PATH=$(shell xcrun --show-sdk-path --sdk macosx)
Menuscript.app: src/* examples/*
Menuscript.app: src/* res/**
@mkdir -p Menuscript.app/Contents/MacOS/
swiftc ${CFLAGS} src/main.swift -target x86_64-apple-macos10.13 \
-emit-executable -sdk ${SDK_PATH} -o bin_x64
@@ -20,11 +20,10 @@ Menuscript.app: src/* examples/*
lipo -create bin_x64 bin_arm64 -o Menuscript.app/Contents/MacOS/Menuscript
@rm bin_x64 bin_arm64
@echo 'APPL????' > Menuscript.app/Contents/PkgInfo
@mkdir -p Menuscript.app/Contents/Resources/
@cp src/AppIcon.icns Menuscript.app/Contents/Resources/AppIcon.icns
@rm -rf Menuscript.app/Contents/Resources/examples/
@cp -R examples/ Menuscript.app/Contents/Resources/examples/
@cp src/Info.plist Menuscript.app/Contents/Info.plist
@find res -name .DS_Store -delete
@rm -rf Menuscript.app/Contents/Resources/
@cp -R res/ Menuscript.app/Contents/Resources/
@touch Menuscript.app
@echo
ifeq ($(HAS_SIGN_IDENTITY),1)

View File

@@ -12,7 +12,7 @@ A menu bar script executor.
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 screenshot above represents the content of the [examples](examples/) directory.
See [res/examples](res/examples/) directory.
## Usage
@@ -36,13 +36,18 @@ Apart from that, there is no limitation on the script language.
You can use Bash, Python, Swift, Ruby, whatever.
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
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
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:
- __[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).

View File

@@ -21,7 +21,7 @@
<rect id="blue" x="500" width="600" height="470" fill="url(#lg2)"/>
<rect id="blue_line" x="500" y="470" width="600" height="30" fill="#2847C5"/>
<g transform="translate(562.5,62)scale(0.35)">
<path id="_x2318_" d="M335,335m-145,0a145,145,0,1,1,145,-145v620a145,145,0,1,1,-145,-145h620a145,145,0,1,1,-145,145v-620a145,145,0,1,1,145,145Z" stroke="#fff" stroke-width="90" fill="transparent"/>
<path id="_x2318_" d="M335,335m-145,0a145,145,0,1,1,145,-145v620a145,145,0,1,1,-145,-145h620a145,145,0,1,1,-145,145v-620a145,145,0,1,1,145,145Z" stroke="#fff" stroke-width="90" fill="none"/>
</g>
<text x="75" y="840">run.sh</text>
</g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -21,7 +21,7 @@
<rect id="blue" x="512" width="402" height="331" fill="url(#lg2)"/>
<rect id="blue_line" x="512" y="331" width="402" height="20" fill="#2847C5"/>
<g transform="translate(587,40) scale(0.251)">
<path id="_x2318_" d="M335,335m-145,0a145,145,0,1,1,145,-145v620a145,145,0,1,1,-145,-145h620a145,145,0,1,1,-145,145v-620a145,145,0,1,1,145,145Z" stroke="#fff" stroke-width="90" fill="transparent"/>
<path id="_x2318_" d="M335,335m-145,0a145,145,0,1,1,145,-145v620a145,145,0,1,1,-145,-145h620a145,145,0,1,1,-145,145v-620a145,145,0,1,1,145,145Z" stroke="#fff" stroke-width="90" fill="none"/>
</g>
<text x="72" y="590">run.sh</text>
<rect id="sep" y="672" width="1024" height="20" fill="#ccc"/>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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

View File

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

3
res/status.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<path id="_x2318_" d="M335,335m-145,0a145,145,0,1,1,145,-145v620a145,145,0,1,1,-145,-145h620a145,145,0,1,1,-145,145v-620a145,145,0,1,1,145,145Z" stroke="black" stroke-width="90" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

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

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env swift
import AppKit
import Cocoa
import SwiftUI
class FlippedView: NSView {
override var isFlipped: Bool { true }
@@ -21,12 +20,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func initStatusIcon() {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.statusItem.button?.title = ""
// self.statusItem.button?.image = NSImage.statusIcon
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)
@@ -43,15 +52,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
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: "")
itm.representedObject = entry
itm.image = entry.icon()
if entry.isDir {
if entry.isDir || entry.action == .Dynamic {
itm.submenu = NSMenu(title: entry.url.path)
itm.submenu?.delegate = self
} else {
} else if entry.action != .Inactive {
itm.action = #selector(menuItemCallback)
}
}
@@ -64,20 +76,28 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
@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
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()
// if run via .app bundle
?? Bundle.main.url(forResource: "examples", withExtension: nil)
// if calling swift directly
?? URL(string: "file://" + FileManager.default.currentDirectoryPath + "/" + #file)!
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("examples")
userStorageURL() ?? resFile("examples", nil)
}
private func userStorageURL() -> URL? {
@@ -159,115 +179,169 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
// MARK: - A menu item
struct Entry {
enum Flags {
case Verbose
case Text
// 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),
!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 title: String
let flags: [Flags]
let url: URL
let title: String
let action: ActionFlag
let hasDynParent: Bool
var isDir: Bool { url.hasDirectoryPath }
init(_ url: URL) {
init(_ url: URL, title: String? = nil) {
self.hasDynParent = title != nil
self.url = url
// TODO: remove file extension?
var fname = url.lastPathComponent
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
// sort order
if let order = Int(fname[..<idx]) {
self.order = 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])
} else {
self.order = 100
}
// 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)
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.flags = flags
self.order = customOrder
self.action = ActionFlag.from(&fname)
self.title = fname.trimmingCharacters(in: .whitespaces)
}
static func listDir(_ path: String) -> [Entry] {
var rv: [Entry] = []
for url
in (try? FileManager.default.contentsOfDirectory(
at: URL(string: path)!,
includingPropertiesForKeys: [.isDirectoryKey, .isExecutableKey])) ?? []
{
if url.hasDirectoryPath || FileManager.default.isExecutableFile(atPath: url.path) {
rv.append(Entry(url))
}
}
return rv.sorted()
}
func icon() -> NSImage? {
guard isDir else {
return nil
}
var img: NSImage? = nil
for ext in ["svg", "png", "jpg", "jpeg", "gif", "ico"] {
let iconPath = self.url.appendingPathComponent("icon." + ext)
if FileManager.default.fileExists(atPath: iconPath.path) {
img = NSImage(contentsOf: iconPath)
break
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 {
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
}
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 {
static func < (lhs: Entry, rhs: Entry) -> Bool {
return (lhs.order, lhs.title) < (rhs.order, rhs.title)
}