2026-09-11 10:27:08 -04:00
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Where things are, and which settings belong to whom.
|
|
|
|
|
|
|
|
|
|
Two kinds of setting live in this app, and they are deliberately kept apart:
|
|
|
|
|
|
|
|
|
|
* **ProdDash settings** (the port) belong to ProdDash, so they are written
|
|
|
|
|
where ProdDash already looks for them — `proddash.json` in the per-machine
|
|
|
|
|
data directory. Start the server any other way (`Start ProdDash.command`,
|
|
|
|
|
a terminal) and it still comes up on the port set here.
|
|
|
|
|
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
|
|
|
|
|
lives) belong to this app and live in its own preferences.
|
|
|
|
|
|
|
|
|
|
The data-directory and port rules mirror server.js exactly — if that file's
|
|
|
|
|
`resolveDataDir` / `PORT` logic changes, change it here too.
|
|
|
|
|
*/
|
|
|
|
|
enum ProdDash {
|
|
|
|
|
|
|
|
|
|
// MARK: - Data directory (mirrors server.js resolveDataDir)
|
|
|
|
|
|
|
|
|
|
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
|
|
|
|
|
/// against the checkout, as in server.js), else the per-machine default.
|
|
|
|
|
static func dataDir(root: URL?) -> URL {
|
|
|
|
|
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
|
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
if !fromEnv.isEmpty {
|
|
|
|
|
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
|
|
|
|
|
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
|
|
|
|
|
}
|
|
|
|
|
return FileManager.default.homeDirectoryForCurrentUser
|
|
|
|
|
.appendingPathComponent("Library/Application Support/ProdDash")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 12:38:46 -04:00
|
|
|
// MARK: - What the app carries
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
A shipped ProdDash lives read-only inside the app bundle and runs from a
|
|
|
|
|
copy in the data directory.
|
|
|
|
|
|
|
|
|
|
The copy isn't ceremony: ProdDash updates itself in place — the admin
|
|
|
|
|
page's "Update now" writes a fresh download over the app folder — and
|
|
|
|
|
nothing may write inside a signed .app without breaking its signature.
|
|
|
|
|
So the bundle carries the baseline, the data directory carries the copy
|
|
|
|
|
that actually runs, and both routes to a new version keep working: drop
|
|
|
|
|
in a newer .app, or press Update now.
|
|
|
|
|
|
|
|
|
|
A development build ships no payload and falls through to the checkout it
|
|
|
|
|
was built in.
|
|
|
|
|
*/
|
|
|
|
|
static func bundledPayload() -> URL? {
|
|
|
|
|
guard let resources = Bundle.main.resourceURL else { return nil }
|
|
|
|
|
let payload = resources.appendingPathComponent("app")
|
|
|
|
|
return isRoot(payload) ? payload : nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The Node the app carries, so nothing depends on what this Mac happens
|
|
|
|
|
/// to have installed — or on it staying installed.
|
|
|
|
|
static func bundledNode() -> URL? {
|
|
|
|
|
guard let resources = Bundle.main.resourceURL else { return nil }
|
|
|
|
|
let node = resources.appendingPathComponent("node/bin/node")
|
|
|
|
|
return FileManager.default.isExecutableFile(atPath: node.path) ? node : nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func workingCopy(dataDir: URL) -> URL { dataDir.appendingPathComponent("app") }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Put the bundled ProdDash in place: when there is no copy yet, or when the
|
|
|
|
|
app carries a newer one than the copy — someone installed a new .app. A
|
|
|
|
|
copy that has updated itself *past* the bundle is left alone, so a new
|
|
|
|
|
install never drags a running system backwards.
|
|
|
|
|
*/
|
|
|
|
|
@discardableResult
|
|
|
|
|
static func seedWorkingCopy(dataDir: URL, log: (String) -> Void) -> URL? {
|
|
|
|
|
guard let payload = bundledPayload() else { return nil }
|
|
|
|
|
let working = workingCopy(dataDir: dataDir)
|
|
|
|
|
let shipped = shellVersion(root: payload)
|
|
|
|
|
if isRoot(working) {
|
|
|
|
|
let installed = shellVersion(root: working)
|
|
|
|
|
guard compareVersions(shipped, installed) > 0 else { return working }
|
|
|
|
|
log("this app carries ProdDash \(shipped) — replacing the \(installed) copy it runs")
|
|
|
|
|
} else {
|
|
|
|
|
log("first run — installing ProdDash \(shipped)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fm = FileManager.default
|
|
|
|
|
let previous = working.appendingPathExtension("previous")
|
|
|
|
|
do {
|
|
|
|
|
try fm.createDirectory(at: dataDir, withIntermediateDirectories: true)
|
|
|
|
|
try? fm.removeItem(at: previous)
|
|
|
|
|
if fm.fileExists(atPath: working.path) { try fm.moveItem(at: working, to: previous) }
|
|
|
|
|
try fm.copyItem(at: payload, to: working)
|
|
|
|
|
makeWritable(working)
|
|
|
|
|
try? fm.removeItem(at: previous)
|
|
|
|
|
return working
|
|
|
|
|
} catch {
|
|
|
|
|
log("could not install the bundled ProdDash: \(error.localizedDescription)")
|
|
|
|
|
// Put back whatever was working before rather than leaving nothing.
|
|
|
|
|
if !fm.fileExists(atPath: working.path), fm.fileExists(atPath: previous.path) {
|
|
|
|
|
try? fm.moveItem(at: previous, to: working)
|
|
|
|
|
}
|
|
|
|
|
return isRoot(working) ? working : nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 16:16:46 -04:00
|
|
|
/**
|
|
|
|
|
Go back to the copy ProdDash set aside before its last update.
|
|
|
|
|
|
|
|
|
|
A version that will not stay up is the one state this app cannot talk its
|
|
|
|
|
way out of: the admin page went down with it, and installing a fresh
|
|
|
|
|
`.app` does nothing, because seeding only replaces an *older* copy and a
|
|
|
|
|
broken one carries a higher version. So ProdDash writes `app.previous`
|
|
|
|
|
before it overwrites itself and drops it again once the new version has
|
|
|
|
|
stayed up — meaning that folder exists only while an update is still
|
|
|
|
|
unproven. This puts it back.
|
|
|
|
|
|
|
|
|
|
Returns the version restored, or nil when there is nothing to go back to.
|
|
|
|
|
*/
|
|
|
|
|
@discardableResult
|
|
|
|
|
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
|
|
|
|
let fm = FileManager.default
|
|
|
|
|
let previous = URL(fileURLWithPath: root.path + ".previous")
|
|
|
|
|
guard isRoot(previous) else { return nil }
|
|
|
|
|
let version = shellVersion(root: previous)
|
|
|
|
|
let named = version.isEmpty ? "(unknown version)" : version
|
|
|
|
|
let broken = URL(fileURLWithPath: root.path + ".broken")
|
|
|
|
|
do {
|
|
|
|
|
try? fm.removeItem(at: broken)
|
|
|
|
|
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
|
|
|
|
try fm.moveItem(at: previous, to: root)
|
|
|
|
|
makeWritable(root)
|
|
|
|
|
try? fm.removeItem(at: broken)
|
|
|
|
|
log("rolled ProdDash back to \(named) — the copy it replaced")
|
|
|
|
|
return named
|
|
|
|
|
} catch {
|
|
|
|
|
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
|
|
|
|
// Leave something runnable behind rather than nothing at all.
|
|
|
|
|
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
|
|
|
|
try? fm.moveItem(at: broken, to: root)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 12:38:46 -04:00
|
|
|
/// Files copied out of a bundle can arrive read-only, and this copy is
|
|
|
|
|
/// exactly where ProdDash writes its own updates.
|
|
|
|
|
private static func makeWritable(_ root: URL) {
|
|
|
|
|
let fm = FileManager.default
|
|
|
|
|
var targets = [root]
|
|
|
|
|
if let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) {
|
|
|
|
|
for case let url as URL in walker { targets.append(url) }
|
|
|
|
|
}
|
|
|
|
|
for url in targets {
|
|
|
|
|
guard let mode = (try? fm.attributesOfItem(atPath: url.path))?[.posixPermissions] as? NSNumber
|
|
|
|
|
else { continue }
|
|
|
|
|
let writable = mode.uint16Value | 0o200
|
|
|
|
|
if writable != mode.uint16Value {
|
|
|
|
|
try? fm.setAttributes([.posixPermissions: NSNumber(value: writable)], ofItemAtPath: url.path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 10:27:08 -04:00
|
|
|
// MARK: - Finding the checkout
|
|
|
|
|
|
|
|
|
|
/// A folder is ProdDash if it has the two files the server cannot run without.
|
|
|
|
|
static func isRoot(_ url: URL) -> Bool {
|
|
|
|
|
let fm = FileManager.default
|
|
|
|
|
return fm.fileExists(atPath: url.appendingPathComponent("server.js").path)
|
|
|
|
|
&& fm.fileExists(atPath: url.appendingPathComponent("public/index.html").path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Find the ProdDash folder: the one the operator chose, else the checkout
|
|
|
|
|
this .app was built inside (the app usually sits in `launcher/macos/build`),
|
|
|
|
|
else the handful of places people actually keep it.
|
|
|
|
|
*/
|
2026-09-11 12:38:46 -04:00
|
|
|
static func findRoot(preferred: String?, dataDir: URL) -> URL? {
|
2026-09-11 10:27:08 -04:00
|
|
|
if let preferred, !preferred.isEmpty {
|
|
|
|
|
let url = URL(fileURLWithPath: preferred).standardizedFileURL
|
|
|
|
|
if isRoot(url) { return url }
|
|
|
|
|
}
|
|
|
|
|
if let env = ProcessInfo.processInfo.environment["PRODDASH_ROOT"], !env.isEmpty {
|
|
|
|
|
let url = URL(fileURLWithPath: env).standardizedFileURL
|
|
|
|
|
if isRoot(url) { return url }
|
|
|
|
|
}
|
2026-09-11 12:38:46 -04:00
|
|
|
// A shipped app runs the copy it installed; only a development build
|
|
|
|
|
// (no payload) goes looking for a checkout.
|
|
|
|
|
if bundledPayload() != nil {
|
|
|
|
|
let working = workingCopy(dataDir: dataDir)
|
|
|
|
|
if isRoot(working) { return working }
|
|
|
|
|
}
|
2026-09-11 10:27:08 -04:00
|
|
|
var up = Bundle.main.bundleURL.standardizedFileURL
|
|
|
|
|
for _ in 0..<6 {
|
|
|
|
|
up.deleteLastPathComponent()
|
|
|
|
|
if up.path == "/" { break }
|
|
|
|
|
if isRoot(up) { return up }
|
|
|
|
|
}
|
|
|
|
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
|
|
|
var candidates = [
|
|
|
|
|
home.appendingPathComponent("Apps/ProdDash"),
|
|
|
|
|
home.appendingPathComponent("ProdDash"),
|
|
|
|
|
home.appendingPathComponent("Documents/ProdDash"),
|
|
|
|
|
URL(fileURLWithPath: "/Applications/ProdDash"),
|
|
|
|
|
URL(fileURLWithPath: "/Users/Shared/ProdDash"),
|
|
|
|
|
]
|
|
|
|
|
// …and next to the app itself, for a "ProdDash Launcher.app beside the folder" layout.
|
|
|
|
|
candidates.append(Bundle.main.bundleURL.deletingLastPathComponent()
|
|
|
|
|
.appendingPathComponent("ProdDash"))
|
|
|
|
|
return candidates.first(where: isRoot)?.standardizedFileURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The shell's version, straight from the checkout's package.json.
|
|
|
|
|
static func shellVersion(root: URL) -> String {
|
|
|
|
|
guard let data = try? Data(contentsOf: root.appendingPathComponent("package.json")),
|
|
|
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
|
|
|
let version = json["version"] as? String else { return "" }
|
|
|
|
|
return version
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Finding node
|
|
|
|
|
|
|
|
|
|
/// Everywhere a Mac plausibly keeps node, in the order worth trying.
|
|
|
|
|
static func findNode(root: URL?, preferred: String?) -> URL? {
|
|
|
|
|
var tries: [URL] = []
|
|
|
|
|
if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) }
|
2026-09-11 12:38:46 -04:00
|
|
|
// What the app carries, so a shipped ProdDash needs nothing installed.
|
|
|
|
|
if let bundled = bundledNode() { tries.append(bundled) }
|
2026-09-11 10:27:08 -04:00
|
|
|
// The runtime a production machine ships with — same preference as Start ProdDash.command.
|
|
|
|
|
if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) }
|
|
|
|
|
tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"]
|
|
|
|
|
.map { URL(fileURLWithPath: $0) }
|
|
|
|
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
|
|
|
tries += newestNodeUnder(home.appendingPathComponent(".nvm/versions/node"))
|
|
|
|
|
tries += [home.appendingPathComponent(".volta/bin/node")]
|
|
|
|
|
if let found = tries.first(where: isExecutable) { return found }
|
|
|
|
|
// Last resort: ask a login shell, which is where a fancier version
|
|
|
|
|
// manager (asdf, fnm, mise) puts node on the PATH.
|
|
|
|
|
if let fromShell = loginShellNode(), isExecutable(fromShell) { return fromShell }
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func isExecutable(_ url: URL) -> Bool {
|
|
|
|
|
FileManager.default.isExecutableFile(atPath: url.path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func newestNodeUnder(_ dir: URL) -> [URL] {
|
|
|
|
|
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] }
|
|
|
|
|
return names.sorted { compareVersions($0, $1) > 0 }
|
|
|
|
|
.map { dir.appendingPathComponent($0).appendingPathComponent("bin/node") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func loginShellNode() -> URL? {
|
|
|
|
|
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
|
|
|
|
let task = Process()
|
|
|
|
|
task.executableURL = URL(fileURLWithPath: shell)
|
|
|
|
|
task.arguments = ["-lc", "command -v node"]
|
|
|
|
|
let pipe = Pipe()
|
|
|
|
|
task.standardOutput = pipe
|
|
|
|
|
task.standardError = FileHandle.nullDevice
|
|
|
|
|
do { try task.run() } catch { return nil }
|
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
|
|
|
task.waitUntilExit()
|
|
|
|
|
let path = String(data: data, encoding: .utf8)?
|
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
return path.hasPrefix("/") ? URL(fileURLWithPath: path) : nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `node -v`, so the window can say which runtime is about to be used.
|
|
|
|
|
static func nodeVersion(_ node: URL) -> String {
|
|
|
|
|
let task = Process()
|
|
|
|
|
task.executableURL = node
|
|
|
|
|
task.arguments = ["-v"]
|
|
|
|
|
let pipe = Pipe()
|
|
|
|
|
task.standardOutput = pipe
|
|
|
|
|
task.standardError = FileHandle.nullDevice
|
|
|
|
|
do { try task.run() } catch { return "" }
|
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
|
|
|
task.waitUntilExit()
|
|
|
|
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// node 18 is the floor the server's package.json declares.
|
|
|
|
|
static func nodeIsTooOld(_ version: String) -> Bool {
|
|
|
|
|
let major = Int(version.dropFirst(version.hasPrefix("v") ? 1 : 0)
|
|
|
|
|
.prefix(while: { $0.isNumber })) ?? 0
|
|
|
|
|
return major > 0 && major < 18
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - The port (mirrors server.js: PORT env, then data dir, then checkout, then 24500)
|
|
|
|
|
|
|
|
|
|
static let defaultPort = 24500
|
|
|
|
|
|
|
|
|
|
static func port(root: URL?, dataDir: URL) -> Int {
|
|
|
|
|
if let fromFile = jsonNumber(at: dataDir.appendingPathComponent("proddash.json"), key: "port") {
|
|
|
|
|
return fromFile
|
|
|
|
|
}
|
|
|
|
|
if let root, let fromRepo = jsonNumber(at: root.appendingPathComponent("config/proddash.json"), key: "port") {
|
|
|
|
|
return fromRepo
|
|
|
|
|
}
|
|
|
|
|
return defaultPort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Write the port into the data directory's proddash.json, preserving every
|
|
|
|
|
other key (the admin passcode lives in the same file). The launcher also
|
|
|
|
|
passes PORT to the server it starts, so this write is what keeps a start
|
|
|
|
|
by any other means on the same port.
|
|
|
|
|
*/
|
|
|
|
|
static func setPort(_ port: Int, dataDir: URL) throws {
|
|
|
|
|
let file = dataDir.appendingPathComponent("proddash.json")
|
|
|
|
|
var object: [String: Any] = [:]
|
|
|
|
|
if let data = try? Data(contentsOf: file),
|
|
|
|
|
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
|
|
|
object = existing
|
|
|
|
|
}
|
|
|
|
|
object["port"] = port
|
|
|
|
|
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)
|
|
|
|
|
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
|
|
|
|
// Same atomic-ish write the server uses: never leave a half-written config.
|
|
|
|
|
let tmp = file.appendingPathExtension("tmp")
|
|
|
|
|
try Data((inHouseStyle(data) + "\n").utf8).write(to: tmp)
|
|
|
|
|
_ = try FileManager.default.replaceItemAt(file, withItemAt: tmp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Foundation writes `"port" : 24500`; the server writes `"port": 24500`.
|
|
|
|
|
/// This file is read and hand-edited by people — keep one house style.
|
|
|
|
|
private static func inHouseStyle(_ json: Data) -> String {
|
|
|
|
|
(String(data: json, encoding: .utf8) ?? "")
|
|
|
|
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
|
|
|
.map { line in
|
|
|
|
|
guard line.trimmingCharacters(in: .whitespaces).hasPrefix("\""),
|
|
|
|
|
let colon = line.range(of: "\" : ") else { return String(line) }
|
|
|
|
|
return line.replacingCharacters(in: colon, with: "\": ")
|
|
|
|
|
}
|
|
|
|
|
.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func isValidPort(_ port: Int) -> Bool { port >= 1 && port <= 65535 }
|
|
|
|
|
|
|
|
|
|
private static func jsonNumber(at url: URL, key: String) -> Int? {
|
|
|
|
|
guard let data = try? Data(contentsOf: url),
|
|
|
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
|
|
|
let value = json[key] as? NSNumber else { return nil }
|
|
|
|
|
let port = value.intValue
|
|
|
|
|
return isValidPort(port) ? port : nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Misc
|
|
|
|
|
|
|
|
|
|
/// "1.10.0" > "1.9.0" — the numeric comparison version strings deserve.
|
|
|
|
|
static func compareVersions(_ a: String, _ b: String) -> Int {
|
|
|
|
|
let lhs = a.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
|
|
|
|
let rhs = b.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
|
|
|
|
for i in 0..<max(lhs.count, rhs.count) {
|
|
|
|
|
let l = i < lhs.count ? lhs[i] : 0
|
|
|
|
|
let r = i < rhs.count ? rhs[i] : 0
|
|
|
|
|
if l != r { return l < r ? -1 : 1 }
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|