Skip to content

Commit d4f9f6a

Browse files
authored
Merge pull request #1 from ReScienceLab/raycast-extension
Add Raycast extension
2 parents d61ee0b + 72b75c7 commit d4f9f6a

42 files changed

Lines changed: 3702 additions & 97 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release-cli.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Release CLI
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
build-macos:
14+
name: Build macOS CLI
15+
runs-on: macos-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
target:
20+
- aarch64-apple-darwin
21+
- x86_64-apple-darwin
22+
steps:
23+
- name: Validate release tag
24+
if: startsWith(github.ref, 'refs/tags/')
25+
run: |
26+
if ! echo "${GITHUB_REF_NAME}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
27+
echo "Release tags must use semantic version format: vX.Y.Z"
28+
exit 1
29+
fi
30+
31+
- name: Checkout
32+
uses: actions/checkout@v4
33+
34+
- name: Install Rust target
35+
run: rustup target add ${{ matrix.target }}
36+
37+
- name: Build perch CLI
38+
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
39+
40+
- name: Package binary
41+
run: |
42+
cp cli/target/${{ matrix.target }}/release/perch perch-${{ matrix.target }}
43+
shasum -a 256 perch-${{ matrix.target }} > perch-${{ matrix.target }}.sha256
44+
45+
- name: Upload workflow artifacts
46+
uses: actions/upload-artifact@v4
47+
with:
48+
name: perch-${{ matrix.target }}
49+
path: |
50+
perch-${{ matrix.target }}
51+
perch-${{ matrix.target }}.sha256
52+
53+
- name: Upload release assets
54+
if: startsWith(github.ref, 'refs/tags/')
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
run: |
58+
gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 || \
59+
gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes
60+
if ! gh release upload "${GITHUB_REF_NAME}" \
61+
perch-${{ matrix.target }} \
62+
perch-${{ matrix.target }}.sha256 \
63+
--clobber; then
64+
echo "Failed to upload release assets"
65+
exit 1
66+
fi
67+
gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name'

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,11 @@
22
.hal/*
33
!.hal/standards/
44
!.hal/commands/
5+
6+
# Generated planning/spec scratch space
7+
markshare/
8+
9+
# Raycast extension local artifacts
10+
raycast-extension/node_modules/
11+
raycast-extension/dist/
12+
raycast-extension/.pi-lens/
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import Foundation
2+
3+
enum LaunchAtLoginError: LocalizedError, Equatable {
4+
case executableNotFound(String)
5+
case executableNotExecutable(String)
6+
case launchctlFailed(arguments: [String], status: Int32, stderr: String)
7+
8+
var errorDescription: String? {
9+
switch self {
10+
case let .executableNotFound(path):
11+
return "Perch app executable does not exist: \(path)"
12+
case let .executableNotExecutable(path):
13+
return "Perch app executable is not executable: \(path)"
14+
case let .launchctlFailed(arguments, status, stderr):
15+
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
16+
if detail.isEmpty {
17+
return "launchctl \(arguments.joined(separator: " ")) failed with status \(status)"
18+
}
19+
return "launchctl \(arguments.joined(separator: " ")) failed with status \(status): \(detail)"
20+
}
21+
}
22+
}
23+
24+
struct LaunchAtLoginManager {
25+
typealias LaunchctlRunner = (_ arguments: [String]) throws -> Void
26+
27+
static let label = "com.resciencelab.perch"
28+
29+
static var launchAgentsDirectory: URL {
30+
FileManager.default.homeDirectoryForCurrentUser
31+
.appendingPathComponent("Library", isDirectory: true)
32+
.appendingPathComponent("LaunchAgents", isDirectory: true)
33+
}
34+
35+
static var plistURL: URL {
36+
launchAgentsDirectory.appendingPathComponent("\(label).plist")
37+
}
38+
39+
static var currentExecutablePath: String {
40+
Bundle.main.executablePath ?? CommandLine.arguments.first ?? ""
41+
}
42+
43+
static func isEnabled(plistURL: URL = plistURL) -> Bool {
44+
FileManager.default.fileExists(atPath: plistURL.path)
45+
}
46+
47+
static func setEnabled(
48+
_ enabled: Bool,
49+
executablePath: String = currentExecutablePath,
50+
plistURL: URL = plistURL,
51+
launchctl: LaunchctlRunner = runLaunchctl
52+
) throws {
53+
if enabled {
54+
try enable(executablePath: executablePath, plistURL: plistURL, launchctl: launchctl)
55+
} else {
56+
try disable(plistURL: plistURL, launchctl: launchctl)
57+
}
58+
}
59+
60+
static func enable(
61+
executablePath: String,
62+
plistURL: URL = plistURL,
63+
launchctl: LaunchctlRunner = runLaunchctl
64+
) throws {
65+
try validateExecutable(at: executablePath)
66+
try FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true)
67+
try plistContents(executablePath: executablePath).write(to: plistURL, atomically: true, encoding: .utf8)
68+
69+
do {
70+
try? launchctl(["bootout", "gui/\(getuid())", plistURL.path])
71+
try launchctl(["bootstrap", "gui/\(getuid())", plistURL.path])
72+
try launchctl(["enable", "gui/\(getuid())/\(label)"])
73+
} catch {
74+
try? FileManager.default.removeItem(at: plistURL)
75+
throw error
76+
}
77+
}
78+
79+
static func disable(plistURL: URL = plistURL, launchctl: LaunchctlRunner = runLaunchctl) throws {
80+
try? launchctl(["bootout", "gui/\(getuid())", plistURL.path])
81+
if FileManager.default.fileExists(atPath: plistURL.path) {
82+
try FileManager.default.removeItem(at: plistURL)
83+
}
84+
}
85+
86+
static func validateExecutable(at path: String, fileManager: FileManager = .default) throws {
87+
guard fileManager.fileExists(atPath: path) else {
88+
throw LaunchAtLoginError.executableNotFound(path)
89+
}
90+
guard fileManager.isExecutableFile(atPath: path) else {
91+
throw LaunchAtLoginError.executableNotExecutable(path)
92+
}
93+
}
94+
95+
static func plistContents(executablePath: String) -> String {
96+
"""
97+
<?xml version="1.0" encoding="UTF-8"?>
98+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99+
<plist version="1.0">
100+
<dict>
101+
<key>Label</key>
102+
<string>\(xmlEscape(label))</string>
103+
<key>ProgramArguments</key>
104+
<array>
105+
<string>\(xmlEscape(executablePath))</string>
106+
</array>
107+
<key>RunAtLoad</key>
108+
<true/>
109+
<key>KeepAlive</key>
110+
<false/>
111+
</dict>
112+
</plist>
113+
"""
114+
}
115+
116+
static func runLaunchctl(_ arguments: [String]) throws {
117+
let process = Process()
118+
process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
119+
process.arguments = arguments
120+
121+
let stderr = Pipe()
122+
process.standardOutput = nil
123+
process.standardError = stderr
124+
125+
try process.run()
126+
process.waitUntilExit()
127+
128+
guard process.terminationStatus == 0 else {
129+
let data = stderr.fileHandleForReading.readDataToEndOfFile()
130+
let message = String(data: data, encoding: .utf8) ?? ""
131+
throw LaunchAtLoginError.launchctlFailed(arguments: arguments, status: process.terminationStatus, stderr: message)
132+
}
133+
}
134+
135+
private static func xmlEscape(_ value: String) -> String {
136+
value
137+
.replacingOccurrences(of: "&", with: "&amp;")
138+
.replacingOccurrences(of: "\"", with: "&quot;")
139+
.replacingOccurrences(of: "'", with: "&apos;")
140+
.replacingOccurrences(of: "<", with: "&lt;")
141+
.replacingOccurrences(of: ">", with: "&gt;")
142+
}
143+
}
File renamed without changes.

App/Sources/PerchAppCore/Resources/perch.svg

Lines changed: 0 additions & 4 deletions
This file was deleted.

App/Sources/PerchAppCore/StatusMenuController.swift

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ enum StatusMenuLogic {
3434
return "cd '\(dir)' && \(session.resumeCmd)"
3535
}
3636

37+
static func projectLabel(from workingDir: String) -> String {
38+
guard !workingDir.isEmpty else { return "" }
39+
let name = URL(fileURLWithPath: workingDir).lastPathComponent
40+
return name.isEmpty ? workingDir : name
41+
}
42+
43+
static func groupedByProject(from sessions: [Session]) -> [(label: String, sessions: [Session])] {
44+
var groups: [(label: String, sessions: [Session])] = []
45+
var index: [String: Int] = [:]
46+
for session in sessions {
47+
let key = (session.workingDir as NSString).standardizingPath
48+
let label = projectLabel(from: session.workingDir)
49+
if let i = index[key] {
50+
groups[i].sessions.append(session)
51+
} else {
52+
index[key] = groups.count
53+
groups.append((label: label, sessions: [session]))
54+
}
55+
}
56+
return groups
57+
}
58+
3759
static func relativeTime(from iso8601: String, now: Date = Date()) -> String {
3860
let formatter = ISO8601DateFormatter()
3961
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -68,6 +90,8 @@ private class SessionMenuEntry: NSObject {
6890
public class StatusMenuController: NSObject, NSMenuDelegate {
6991
typealias SessionStatusWriter = (_ id: String, _ status: String) -> Void
7092
typealias SessionDeleter = (_ id: String) -> Void
93+
typealias LaunchAtLoginGetter = () -> Bool
94+
typealias LaunchAtLoginSetter = (_ enabled: Bool) throws -> Void
7195

7296
let statusItem: NSStatusItem
7397
let menu: NSMenu
@@ -77,6 +101,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
77101
private let configLoader: () -> PerchConfig
78102
private let statusWriter: SessionStatusWriter
79103
private let sessionDeleter: SessionDeleter
104+
private let launchAtLoginGetter: LaunchAtLoginGetter
105+
private let launchAtLoginSetter: LaunchAtLoginSetter
80106
private let pasteboard: NSPasteboard
81107
private let toastHandler: ((String, String) -> Void)?
82108
private var toastPanel: NSPanel?
@@ -99,6 +125,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
99125
}
100126
},
101127
sessionDeleter: @escaping SessionDeleter = { id in SessionStore.delete(id: id) },
128+
launchAtLoginGetter: @escaping LaunchAtLoginGetter = { LaunchAtLoginManager.isEnabled() },
129+
launchAtLoginSetter: @escaping LaunchAtLoginSetter = { enabled in try LaunchAtLoginManager.setEnabled(enabled) },
102130
pasteboard: NSPasteboard = .general,
103131
toastHandler: ((String, String) -> Void)? = nil,
104132
watchFile: Bool = true
@@ -110,6 +138,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
110138
self.configLoader = configLoader
111139
self.statusWriter = statusWriter
112140
self.sessionDeleter = sessionDeleter
141+
self.launchAtLoginGetter = launchAtLoginGetter
142+
self.launchAtLoginSetter = launchAtLoginSetter
113143
self.pasteboard = pasteboard
114144
self.toastHandler = toastHandler
115145

@@ -167,8 +197,15 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
167197
emptyItem.isEnabled = false
168198
menu.addItem(emptyItem)
169199
} else {
170-
for session in pending {
171-
menu.addItem(makeSessionItem(session, isDone: false))
200+
let projectGroups = StatusMenuLogic.groupedByProject(from: pending)
201+
let useGroupHeaders = projectGroups.count > 1
202+
for group in projectGroups {
203+
if useGroupHeaders {
204+
menu.addItem(makeProjectHeader(group.label))
205+
}
206+
for session in group.sessions {
207+
menu.addItem(makeSessionItem(session, isDone: false))
208+
}
172209
}
173210
}
174211

@@ -193,6 +230,15 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
193230

194231
menu.addItem(NSMenuItem.separator())
195232

233+
let launchAtLoginItem = NSMenuItem(
234+
title: "Launch at Login",
235+
action: #selector(toggleLaunchAtLogin(_:)),
236+
keyEquivalent: ""
237+
)
238+
launchAtLoginItem.target = self
239+
launchAtLoginItem.state = launchAtLoginGetter() ? .on : .off
240+
menu.addItem(launchAtLoginItem)
241+
196242
let configItem = NSMenuItem(
197243
title: "Open Config",
198244
action: #selector(openConfig),
@@ -219,6 +265,19 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
219265
menu.addItem(quitItem)
220266
}
221267

268+
private func makeProjectHeader(_ label: String) -> NSMenuItem {
269+
let item = NSMenuItem(title: label, action: nil, keyEquivalent: "")
270+
item.isEnabled = false
271+
item.attributedTitle = NSAttributedString(
272+
string: label,
273+
attributes: [
274+
.font: NSFont.systemFont(ofSize: 11, weight: .medium),
275+
.foregroundColor: NSColor.secondaryLabelColor
276+
]
277+
)
278+
return item
279+
}
280+
222281
private func makeSessionItem(_ session: Session, isDone: Bool) -> NSMenuItem {
223282
let time = StatusMenuLogic.relativeTime(from: session.createdAt)
224283
let item = NSMenuItem(
@@ -363,8 +422,18 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
363422
rebuildMenu()
364423
}
365424

425+
@objc func toggleLaunchAtLogin(_ sender: NSMenuItem) {
426+
let nextValue = sender.state != .on
427+
do {
428+
try launchAtLoginSetter(nextValue)
429+
sender.state = nextValue ? .on : .off
430+
} catch {
431+
showCopiedToast(title: "Could Not Update Launch at Login", command: error.localizedDescription)
432+
}
433+
}
434+
366435
func loadStatusIcon() -> NSImage? {
367-
if let url = Bundle.module.url(forResource: "perch-logo-2", withExtension: "svg"),
436+
if let url = Bundle.module.url(forResource: "perch-menubar", withExtension: "svg"),
368437
let image = NSImage(contentsOf: url) {
369438
image.size = NSSize(width: 18, height: 18)
370439
image.isTemplate = true

0 commit comments

Comments
 (0)