8 Commits
v1.0.0 ... main

Author SHA1 Message Date
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
22 changed files with 171 additions and 94 deletions

19
CHANGELOG.md Normal file
View File

@@ -0,0 +1,19 @@
# 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.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.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: SDK_PATH=$(shell xcrun --show-sdk-path --sdk macosx)
Menuscript.app: src/* examples/* Menuscript.app: src/* res/**
@mkdir -p Menuscript.app/Contents/MacOS/ @mkdir -p Menuscript.app/Contents/MacOS/
swiftc ${CFLAGS} src/main.swift -target x86_64-apple-macos10.13 \ swiftc ${CFLAGS} src/main.swift -target x86_64-apple-macos10.13 \
-emit-executable -sdk ${SDK_PATH} -o bin_x64 -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 lipo -create bin_x64 bin_arm64 -o Menuscript.app/Contents/MacOS/Menuscript
@rm bin_x64 bin_arm64 @rm bin_x64 bin_arm64
@echo 'APPL????' > Menuscript.app/Contents/PkgInfo @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 @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 @touch Menuscript.app
@echo @echo
ifeq ($(HAS_SIGN_IDENTITY),1) 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. 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 [examples](examples/) directory. The screenshot above represents the content of the [res/examples](res/examples/) directory.
## Usage ## Usage
@@ -43,6 +43,10 @@ And of course, you can always write a script wrapper to call something else.
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`).
#### 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 +64,9 @@ 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)
#### 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`).

View File

@@ -21,7 +21,7 @@
<rect id="blue" x="500" width="600" height="470" fill="url(#lg2)"/> <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"/> <rect id="blue_line" x="500" y="470" width="600" height="30" fill="#2847C5"/>
<g transform="translate(562.5,62)scale(0.35)"> <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> </g>
<text x="75" y="840">run.sh</text> <text x="75" y="840">run.sh</text>
</g> </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" x="512" width="402" height="331" fill="url(#lg2)"/>
<rect id="blue_line" x="512" y="331" width="402" height="20" fill="#2847C5"/> <rect id="blue_line" x="512" y="331" width="402" height="20" fill="#2847C5"/>
<g transform="translate(587,40) scale(0.251)"> <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> </g>
<text x="72" y="590">run.sh</text> <text x="72" y="590">run.sh</text>
<rect id="sep" y="672" width="1024" height="20" fill="#ccc"/> <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

View File

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 214 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> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.0</string> <string>1.0.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>2</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.13</string> <string>10.13</string>
<key>LSBackgroundOnly</key> <key>LSBackgroundOnly</key>

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env swift #!/usr/bin/env swift
import AppKit import AppKit
import Cocoa import Cocoa
import SwiftUI
class FlippedView: NSView { class FlippedView: NSView {
override var isFlipped: Bool { true } override var isFlipped: Bool { true }
@@ -21,12 +20,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func initStatusIcon() { private func initStatusIcon() {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.statusItem.button?.title = "" if let img = statusIcon() {
// self.statusItem.button?.image = NSImage.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 = NSMenu(title: resolvedStorageURL().path)
self.statusItem.menu?.delegate = self 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) { func menuDidClose(_ menu: NSMenu) {
if menu == self.statusItem.menu { if menu == self.statusItem.menu {
self.statusItem.menu = NSMenu(title: menu.title) self.statusItem.menu = NSMenu(title: menu.title)
@@ -43,7 +52,10 @@ 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()
@@ -51,7 +63,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if entry.isDir { if entry.isDir {
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)
} }
} }
@@ -64,20 +76,28 @@ 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, args: nil)
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 // MARK: - Manage storage path
private func resolvedStorageURL() -> URL { private func resolvedStorageURL() -> URL {
userStorageURL() userStorageURL() ?? resFile("examples", nil)
// 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")
} }
private func userStorageURL() -> URL? { private func userStorageURL() -> URL? {
@@ -159,18 +179,104 @@ 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 args: [String]? = nil
private func prepare() -> Process {
let proc = Process()
// proc.environment = ["HOME": "$HOME"]
proc.currentDirectoryURL = self.file.deletingLastPathComponent()
proc.executableURL = self.file
proc.arguments = args
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 read() -> String {
let data = toPipe().fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
}
// MARK: - static methods
func listDir(_ path: String) -> [Entry] {
let target = URL(fileURLWithPath: path)
var rv: [Entry] = []
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
static func from(_ filename: inout String) -> Self {
for (key, flag) in [
"[txt]": ActionFlag.Text,
"[verbose]": .Verbose,
"[inactive]": .Inactive,
"[ignore]": .Ignore,
] {
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
var isDir: Bool { url.hasDirectoryPath } var isDir: Bool { url.hasDirectoryPath }
@@ -178,43 +284,20 @@ struct Entry {
self.url = url self.url = url
// TODO: remove file extension? // TODO: remove file extension?
var fname = url.lastPathComponent var fname = url.lastPathComponent
var customOrder = 100
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
// sort order // sort order
if let order = Int(fname[..<idx]) { if let order = Int(fname[..<idx]) {
self.order = order customOrder = order
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx
fname = String(fname[idx..<fname.endIndex]) fname = String(fname[idx..<fname.endIndex])
} else {
self.order = 100
} }
// flags self.order = customOrder
var flags: [Flags] = [] self.action = ActionFlag.from(&fname)
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.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(string: 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 { guard isDir else {
return nil return nil
@@ -233,38 +316,6 @@ struct Entry {
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 {