Two things the audit found, both about the archive path every shipped .app uses. **Settings could be wiped.** The update kept config/modules.json and config/layouts/ by name, but not config/proddash.json — which holds the admin PIN, the theme and the forge pointer. On an install whose data directory IS the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR aimed inside the app), an update wrote the repo's shipped defaults over it: the theme went, and admin silently unlocked. Nothing inside the data directory is written now, wherever it happens to sit, and the two legacy names stay listed for an install whose state never moved out. **A bad update was unrecoverable.** No rollback, and a fresh .app would not help: seeding only replaces an *older* copy, and a broken one carries a higher version — so the fix was deleting a folder nothing told you about. Three changes, in the order they catch things: - The download must parse before anything is swapped in. `node --check` against the new server.js, and a failure is reported with the syntax error and leaves the running copy untouched. - Past that, the copy being replaced is set aside as `app.previous`, and dropped once the new version has stayed up ninety seconds — so that folder existing means "the last update has not proven itself yet", and never anything older. - The launcher, which already gives up after four stops in two minutes, now looks for that folder before it does and puts the old copy back. Once: if the old one will not run either, the update was not the problem. The launcher half only reaches a machine with a new .app; the server half travels by the normal update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
16 KiB
Swift
362 lines
16 KiB
Swift
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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
*/
|
|
static func findRoot(preferred: String?, dataDir: URL) -> URL? {
|
|
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 }
|
|
}
|
|
// 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 }
|
|
}
|
|
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)) }
|
|
// What the app carries, so a shipped ProdDash needs nothing installed.
|
|
if let bundled = bundledNode() { tries.append(bundled) }
|
|
// 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
|
|
}
|
|
}
|