Merge remote-tracking branch 'origin/feat/shotdeck-20260830' into fix/send-truth-20260902
# Conflicts: # Sources/Shotdeck/PickerSelfTest.swift
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>0.2.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ public final class AppModel {
|
|||||||
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
|
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
|
||||||
private(set) var captureHotkey: HotkeyPreference
|
private(set) var captureHotkey: HotkeyPreference
|
||||||
var hotkeyDisplayString: String { captureHotkey.displayString }
|
var hotkeyDisplayString: String { captureHotkey.displayString }
|
||||||
|
/// Staged update offered in the menu. Set only after checksum + payload validation.
|
||||||
|
public private(set) var updateAvailable: (version: String, notes: String)?
|
||||||
|
|
||||||
let paths: AppSupportPaths
|
let paths: AppSupportPaths
|
||||||
let spool: SpoolStore
|
let spool: SpoolStore
|
||||||
@@ -50,6 +52,7 @@ public final class AppModel {
|
|||||||
let picker: RegionPickerController
|
let picker: RegionPickerController
|
||||||
let ledger: ReturnLedger
|
let ledger: ReturnLedger
|
||||||
let watcher: ReturnWatcher
|
let watcher: ReturnWatcher
|
||||||
|
let updateChecker: UpdateChecker
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
paths: AppSupportPaths,
|
paths: AppSupportPaths,
|
||||||
@@ -85,6 +88,14 @@ public final class AppModel {
|
|||||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||||
self.captureHotkey = HotkeyPreference.load()
|
self.captureHotkey = HotkeyPreference.load()
|
||||||
|
self.updateChecker = UpdateChecker()
|
||||||
|
self.updateChecker.onChecked = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.updateAvailable = self.updateChecker.availableUpdate
|
||||||
|
if let message = self.updateChecker.statusMessage {
|
||||||
|
self.setStatus(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
|
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
|
||||||
@@ -192,6 +203,20 @@ public final class AppModel {
|
|||||||
if !bindCaptureHotkey(pref) {
|
if !bindCaptureHotkey(pref) {
|
||||||
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
|
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let skipSchedule =
|
||||||
|
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|
||||||
|
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||||
|
if !skipSchedule {
|
||||||
|
updateChecker.startSchedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the staged update over `/Applications/Redline.app` and relaunches.
|
||||||
|
/// Does nothing unless the user clicked the menu row.
|
||||||
|
public func installUpdate() {
|
||||||
|
updateChecker.installStaged()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
|
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ struct MenuBarView: View {
|
|||||||
|
|
||||||
private var actionsList: some View {
|
private var actionsList: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let update = model.updateAvailable {
|
||||||
|
Button {
|
||||||
|
model.installUpdate()
|
||||||
|
} label: {
|
||||||
|
actionLabel("Update to \(update.version)")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
let anchor = NSApp.keyWindow?.contentView
|
let anchor = NSApp.keyWindow?.contentView
|
||||||
if let sender = model as? SendCapable {
|
if let sender = model as? SendCapable {
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ enum PickerSelfTest {
|
|||||||
|
|
||||||
runRegionPersistPhase()
|
runRegionPersistPhase()
|
||||||
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
||||||
// exit(0) here — runSendTruthPhase exits when it finishes.
|
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
||||||
|
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
|
||||||
runSendTruthPhase()
|
runSendTruthPhase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,14 +166,16 @@ enum PickerSelfTest {
|
|||||||
/// Fail path must leave the session open in the temp spool; success path archives
|
/// Fail path must leave the session open in the temp spool; success path archives
|
||||||
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
|
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
|
||||||
/// function is called from inside `execute()` — a nested run-loop wait would never
|
/// function is called from inside `execute()` — a nested run-loop wait would never
|
||||||
/// let the Task start.
|
/// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
|
||||||
private static func runSendTruthPhase() {
|
private static func runSendTruthPhase() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
try await executeSendTruth()
|
try await executeSendTruth()
|
||||||
print("SEND-TRUTH PASS")
|
print("SEND-TRUTH PASS")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
if !startUpdateSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("SEND-TRUTH FAIL \(error)")
|
print("SEND-TRUTH FAIL \(error)")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
@@ -297,6 +300,179 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
|
||||||
|
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
|
||||||
|
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||||
|
@discardableResult
|
||||||
|
private static func startUpdateSelfTestIfRequested() -> Bool {
|
||||||
|
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
|
||||||
|
!raw.isEmpty
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let output = URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await runUpdateSelfTest(outputDirectory: output)
|
||||||
|
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(0)
|
||||||
|
} catch let error as UpdateSelfTestError {
|
||||||
|
updateFail(error.description)
|
||||||
|
} catch {
|
||||||
|
updateFail(String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runUpdateSelfTest(outputDirectory: URL) async throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
guard let sourceApp = ownAppBundleURL() else {
|
||||||
|
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
|
||||||
|
if fm.fileExists(atPath: payload.path) {
|
||||||
|
try fm.removeItem(at: payload)
|
||||||
|
}
|
||||||
|
try fm.createDirectory(at: payload, withIntermediateDirectories: true)
|
||||||
|
let fakeApp = payload.appendingPathComponent("Redline.app")
|
||||||
|
try fm.copyItem(at: sourceApp, to: fakeApp)
|
||||||
|
|
||||||
|
let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
|
||||||
|
let plistData = try Data(contentsOf: plistURL)
|
||||||
|
guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
|
||||||
|
throw UpdateSelfTestError.detail("could not parse copied Info.plist")
|
||||||
|
}
|
||||||
|
plist["CFBundleShortVersionString"] = "99.0.0"
|
||||||
|
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
|
try rewritten.write(to: plistURL)
|
||||||
|
|
||||||
|
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
|
||||||
|
if fm.fileExists(atPath: zipURL.path) {
|
||||||
|
try fm.removeItem(at: zipURL)
|
||||||
|
}
|
||||||
|
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
|
||||||
|
|
||||||
|
let zipData = try Data(contentsOf: zipURL)
|
||||||
|
let hex = UpdateChecker.sha256Hex(zipData)
|
||||||
|
|
||||||
|
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
|
||||||
|
let appcast: [String: String] = [
|
||||||
|
"version": "99.0.0",
|
||||||
|
"zipURL": zipURL.absoluteString,
|
||||||
|
"sha256": hex,
|
||||||
|
"notes": "UPDATE-SELFTEST",
|
||||||
|
]
|
||||||
|
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
|
||||||
|
try appcastData.write(to: appcastURL)
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
|
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
|
defer {
|
||||||
|
if let previous {
|
||||||
|
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
|
||||||
|
defer { try? fm.removeItem(at: isolatedRoot) }
|
||||||
|
|
||||||
|
await model.updateChecker.checkNow()
|
||||||
|
|
||||||
|
guard model.updateAvailable?.version == "99.0.0" else {
|
||||||
|
throw UpdateSelfTestError.detail(
|
||||||
|
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let staged = model.updateChecker.stagedAppURL else {
|
||||||
|
throw UpdateSelfTestError.detail("staged payload missing")
|
||||||
|
}
|
||||||
|
guard staged.lastPathComponent == "Redline.app" else {
|
||||||
|
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
|
||||||
|
}
|
||||||
|
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
|
||||||
|
guard fm.fileExists(atPath: stagedExe.path) else {
|
||||||
|
throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
|
||||||
|
if fm.fileExists(atPath: targetRoot.path) {
|
||||||
|
try fm.removeItem(at: targetRoot)
|
||||||
|
}
|
||||||
|
let target = targetRoot.appendingPathComponent("Redline.app")
|
||||||
|
model.updateChecker.installStaged(to: target)
|
||||||
|
|
||||||
|
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
|
||||||
|
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
|
||||||
|
let installedVersion = installed["CFBundleShortVersionString"] as? String
|
||||||
|
else {
|
||||||
|
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
|
||||||
|
}
|
||||||
|
guard installedVersion == "99.0.0" else {
|
||||||
|
throw UpdateSelfTestError.detail("installed version \(installedVersion)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ownAppBundleURL() -> URL? {
|
||||||
|
let bundle = Bundle.main.bundleURL
|
||||||
|
if bundle.pathExtension == "app" { return bundle }
|
||||||
|
let up3 = bundle
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
if up3.pathExtension == "app" { return up3 }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
let paths = try AppSupportPaths(
|
||||||
|
root: root,
|
||||||
|
outbox: root.appendingPathComponent("outbox", isDirectory: true),
|
||||||
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
||||||
|
)
|
||||||
|
let ledger = try ReturnLedger(paths: paths)
|
||||||
|
let model = AppModel(
|
||||||
|
paths: paths,
|
||||||
|
spool: try SpoolStore(paths: paths),
|
||||||
|
composer: PDFComposer(),
|
||||||
|
capturer: ScreenCapturer(),
|
||||||
|
hotkeys: HotkeyCenter(),
|
||||||
|
picker: RegionPickerController(),
|
||||||
|
ledger: ledger,
|
||||||
|
watcher: ReturnWatcher(paths: paths, ledger: ledger)
|
||||||
|
)
|
||||||
|
return (model, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDitto(arguments: [String]) throws {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||||
|
process.arguments = arguments
|
||||||
|
let err = Pipe()
|
||||||
|
process.standardError = err
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||||
|
throw UpdateSelfTestError.detail("ditto failed: \(message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateFail(_ detail: String) -> Never {
|
||||||
|
print("UPDATE-SELFTEST FAIL \(detail)")
|
||||||
|
fflush(stdout)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
private static func interpolate(_ step: Int) -> NSPoint {
|
private static func interpolate(_ step: Int) -> NSPoint {
|
||||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||||
return NSPoint(
|
return NSPoint(
|
||||||
@@ -353,3 +529,12 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
||||||
|
case detail(String)
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .detail(let s): return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import AppKit
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Built-in updater. Checks an appcast, stages a verified payload, and installs
|
||||||
|
/// only when the user clicks the menu row — never automatically.
|
||||||
|
@MainActor
|
||||||
|
final class UpdateChecker {
|
||||||
|
static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL"
|
||||||
|
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
|
||||||
|
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
|
||||||
|
|
||||||
|
private(set) var availableUpdate: (version: String, notes: String)?
|
||||||
|
private(set) var stagedAppURL: URL?
|
||||||
|
private(set) var statusMessage: String?
|
||||||
|
|
||||||
|
var onChecked: (() -> Void)?
|
||||||
|
|
||||||
|
private let urlSession: URLSession
|
||||||
|
private var repeatingTimer: Timer?
|
||||||
|
private var firstCheckTask: Task<Void, Never>?
|
||||||
|
private var isChecking = false
|
||||||
|
private var stagingDirectory: URL?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.timeoutIntervalForRequest = 15
|
||||||
|
config.timeoutIntervalForResource = 15
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieStorage = nil
|
||||||
|
config.urlCache = nil
|
||||||
|
urlSession = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First check 10 seconds after start, then every 6 hours. Stages only — never installs.
|
||||||
|
func startSchedule() {
|
||||||
|
firstCheckTask?.cancel()
|
||||||
|
firstCheckTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.checkNow()
|
||||||
|
}
|
||||||
|
repeatingTimer?.invalidate()
|
||||||
|
let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
await self?.checkNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
repeatingTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNow() async {
|
||||||
|
guard !isChecking else { return }
|
||||||
|
isChecking = true
|
||||||
|
defer { isChecking = false }
|
||||||
|
|
||||||
|
let appcast: Appcast
|
||||||
|
do {
|
||||||
|
appcast = try await fetchAppcast()
|
||||||
|
} catch {
|
||||||
|
statusMessage = "Could not check for updates."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
|
||||||
|
clearOffer()
|
||||||
|
statusMessage = nil
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await downloadAndStage(appcast)
|
||||||
|
availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
|
||||||
|
statusMessage = nil
|
||||||
|
} catch UpdateCheckError.checksumMismatch {
|
||||||
|
discardStaging()
|
||||||
|
availableUpdate = nil
|
||||||
|
statusMessage = "Update file failed the checksum — not installed."
|
||||||
|
} catch {
|
||||||
|
discardStaging()
|
||||||
|
availableUpdate = nil
|
||||||
|
statusMessage = "The update could not be prepared."
|
||||||
|
}
|
||||||
|
onChecked?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
|
||||||
|
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
|
||||||
|
/// can assert the installed Info.plist without killing the process.
|
||||||
|
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
|
||||||
|
guard let staged = stagedAppURL else {
|
||||||
|
statusMessage = "No update is staged."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: target.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
|
||||||
|
} catch {
|
||||||
|
statusMessage = "The update could not be installed."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
||||||
|
if isSelfTest { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
|
||||||
|
} catch {
|
||||||
|
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolvedAppcastURL() -> URL {
|
||||||
|
if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"],
|
||||||
|
!env.isEmpty,
|
||||||
|
let url = URL(string: env)
|
||||||
|
{
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey),
|
||||||
|
!stored.isEmpty,
|
||||||
|
let url = URL(string: stored)
|
||||||
|
{
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return defaultAppcastURL
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentVersion() -> String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isNewer(_ candidate: String, than current: String) -> Bool {
|
||||||
|
let a = semverParts(candidate)
|
||||||
|
let b = semverParts(current)
|
||||||
|
for i in 0..<3 {
|
||||||
|
if a[i] != b[i] { return a[i] > b[i] }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sha256Hex(_ data: Data) -> String {
|
||||||
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private struct Appcast: Decodable {
|
||||||
|
var version: String
|
||||||
|
var zipURL: URL
|
||||||
|
var sha256: String
|
||||||
|
var notes: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum UpdateCheckError: Error {
|
||||||
|
case checksumMismatch
|
||||||
|
case invalidPayload
|
||||||
|
case httpStatus(Int)
|
||||||
|
case processFailed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAppcast() async throws -> Appcast {
|
||||||
|
let data = try await fetchData(from: Self.resolvedAppcastURL())
|
||||||
|
return try JSONDecoder().decode(Appcast.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchData(from url: URL) async throws -> Data {
|
||||||
|
if url.isFileURL {
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
let (data, response) = try await urlSession.data(from: url)
|
||||||
|
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||||
|
throw UpdateCheckError.httpStatus(http.statusCode)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downloadAndStage(_ appcast: Appcast) async throws {
|
||||||
|
let zipData = try await fetchData(from: appcast.zipURL)
|
||||||
|
let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let actual = Self.sha256Hex(zipData)
|
||||||
|
guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
|
||||||
|
throw UpdateCheckError.checksumMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
discardStaging()
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
stagingDirectory = root
|
||||||
|
|
||||||
|
let zipURL = root.appendingPathComponent("update.zip")
|
||||||
|
try zipData.write(to: zipURL)
|
||||||
|
|
||||||
|
let extracted = root.appendingPathComponent("extracted", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true)
|
||||||
|
try Self.runProcess(
|
||||||
|
executable: "/usr/bin/ditto",
|
||||||
|
arguments: ["-x", "-k", zipURL.path, extracted.path]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let appURL = Self.findRedlineApp(in: extracted) else {
|
||||||
|
throw UpdateCheckError.invalidPayload
|
||||||
|
}
|
||||||
|
let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck")
|
||||||
|
guard FileManager.default.fileExists(atPath: executable.path) else {
|
||||||
|
throw UpdateCheckError.invalidPayload
|
||||||
|
}
|
||||||
|
stagedAppURL = appURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearOffer() {
|
||||||
|
availableUpdate = nil
|
||||||
|
discardStaging()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func discardStaging() {
|
||||||
|
if let stagingDirectory {
|
||||||
|
try? FileManager.default.removeItem(at: stagingDirectory)
|
||||||
|
}
|
||||||
|
stagingDirectory = nil
|
||||||
|
stagedAppURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findRedlineApp(in directory: URL) -> URL? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let direct = directory.appendingPathComponent("Redline.app")
|
||||||
|
if fm.fileExists(atPath: direct.path) { return direct }
|
||||||
|
|
||||||
|
guard let enumerator = fm.enumerator(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
while let item = enumerator.nextObject() as? URL {
|
||||||
|
if item.lastPathComponent == "Redline.app" {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
if item.pathExtension == "app" {
|
||||||
|
enumerator.skipDescendants()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func semverParts(_ string: String) -> [Int] {
|
||||||
|
let core = string.split(separator: "-").first.map(String.init) ?? string
|
||||||
|
var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 }
|
||||||
|
while parts.count < 3 { parts.append(0) }
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runProcess(executable: String, arguments: [String]) throws {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: executable)
|
||||||
|
process.arguments = arguments
|
||||||
|
let err = Pipe()
|
||||||
|
process.standardError = err
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||||
|
throw UpdateCheckError.processFailed("\(executable) failed: \(message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+287
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# One-command Redline release: bump Info.plist, commit, signed build, zip,
|
||||||
|
# DMG, appcast, upload to mmd01, verify the public URLs.
|
||||||
|
# Hidden flag: --test — upload under .../redline/test/ and skip the git commit.
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${ROOT}"
|
||||||
|
|
||||||
|
PLIST="${ROOT}/Info.plist"
|
||||||
|
PLISTBUDDY="/usr/libexec/PlistBuddy"
|
||||||
|
REMOTE_HOST="mmd01"
|
||||||
|
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
|
||||||
|
PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline"
|
||||||
|
SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 <version> [\"notes\"]" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_MODE=0
|
||||||
|
VERSION=""
|
||||||
|
NOTES=""
|
||||||
|
NOTES_SET=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "${arg}" in
|
||||||
|
--test)
|
||||||
|
TEST_MODE=1
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
echo "Unknown argument: ${arg}" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
VERSION="${arg}"
|
||||||
|
elif [[ "${NOTES_SET}" -eq 0 ]]; then
|
||||||
|
NOTES="${arg}"
|
||||||
|
NOTES_SET=1
|
||||||
|
else
|
||||||
|
echo "Unexpected extra argument: ${arg}" >&2
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then
|
||||||
|
echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then
|
||||||
|
echo "Version contains illegal path characters: ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||||
|
REMOTE_DIR="${REMOTE_BASE}/test"
|
||||||
|
PUBLIC_DIR="${PUBLIC_BASE}/test"
|
||||||
|
else
|
||||||
|
REMOTE_DIR="${REMOTE_BASE}"
|
||||||
|
PUBLIC_DIR="${PUBLIC_BASE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ZIP_NAME="Redline-${VERSION}.zip"
|
||||||
|
DMG_NAME="Redline-${VERSION}.dmg"
|
||||||
|
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
|
||||||
|
DMG_PATH="${ROOT}/.build/Redline.dmg"
|
||||||
|
APPCAST_PATH="${ROOT}/.build/appcast.json"
|
||||||
|
ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}"
|
||||||
|
APPCAST_URL="${PUBLIC_DIR}/appcast.json"
|
||||||
|
|
||||||
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||||
|
echo "==> Publish Redline ${VERSION} (test)"
|
||||||
|
else
|
||||||
|
echo "==> Publish Redline ${VERSION}"
|
||||||
|
fi
|
||||||
|
echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||||
|
echo " public: ${PUBLIC_DIR}/"
|
||||||
|
|
||||||
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "Not inside a git work tree." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tracked files must match HEAD. Untracked files are ignored so this script
|
||||||
|
# can be dry-run (--test) before it is itself committed.
|
||||||
|
if [[ -n "$(git status --porcelain -uno)" ]]; then
|
||||||
|
echo "git tree is not clean; commit or stash before publishing." >&2
|
||||||
|
git status --porcelain -uno >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${PLISTBUDDY}" ]]; then
|
||||||
|
echo "PlistBuddy not found at ${PLISTBUDDY}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${PLIST}" ]]; then
|
||||||
|
echo "Info.plist not found at ${PLIST}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_plist() {
|
||||||
|
git checkout -- "${PLIST}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
||||||
|
trap restore_plist EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")"
|
||||||
|
if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NEW_BUILD=$((CURRENT_BUILD + 1))
|
||||||
|
|
||||||
|
echo "==> Bumping Info.plist"
|
||||||
|
echo " CFBundleShortVersionString -> ${VERSION}"
|
||||||
|
echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}"
|
||||||
|
"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}"
|
||||||
|
"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}"
|
||||||
|
|
||||||
|
if [[ "${TEST_MODE}" -eq 0 ]]; then
|
||||||
|
echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
git add "${PLIST}"
|
||||||
|
git commit -m "release: v${VERSION}"
|
||||||
|
else
|
||||||
|
echo "==> --test: skipping git commit of version bump"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign.
|
||||||
|
# Re-run signed steps with the account's real home when the identity is missing.
|
||||||
|
signing_home() {
|
||||||
|
if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then
|
||||||
|
echo "${HOME}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local rh
|
||||||
|
rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [[ -n "${rh}" && -d "${rh}" ]]; then
|
||||||
|
echo "${rh}"
|
||||||
|
else
|
||||||
|
echo "${HOME}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_signed() {
|
||||||
|
local sign_home
|
||||||
|
sign_home="$(signing_home)"
|
||||||
|
if [[ "${sign_home}" != "${HOME}" ]]; then
|
||||||
|
echo "==> Using HOME=${sign_home} so codesign can see the login keychain"
|
||||||
|
fi
|
||||||
|
HOME="${sign_home}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Building signed Redline.app"
|
||||||
|
run_signed ./scripts/build-app.sh
|
||||||
|
|
||||||
|
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then
|
||||||
|
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
|
||||||
|
mkdir -p "${ROOT}/.build"
|
||||||
|
(
|
||||||
|
cd "${ROOT}/.build"
|
||||||
|
rm -f "${ZIP_NAME}"
|
||||||
|
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ! -s "${ZIP_PATH}" ]]; then
|
||||||
|
echo "Zip was not created at ${ZIP_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building manual installer DMG"
|
||||||
|
run_signed ./scripts/make-dmg.sh
|
||||||
|
|
||||||
|
if [[ ! -s "${DMG_PATH}" ]]; then
|
||||||
|
echo "DMG was not created at ${DMG_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
|
||||||
|
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
|
||||||
|
PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
|
||||||
|
echo "==> Zip SHA256: ${SHA256}"
|
||||||
|
echo " Zip bytes: ${ZIP_BYTES}"
|
||||||
|
|
||||||
|
echo "==> Writing ${APPCAST_PATH}"
|
||||||
|
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:]
|
||||||
|
payload = {
|
||||||
|
"version": version,
|
||||||
|
"zipURL": zip_url,
|
||||||
|
"sha256": sha256,
|
||||||
|
"notes": notes,
|
||||||
|
"pubDate": pub_date,
|
||||||
|
}
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(payload, fh, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||||
|
ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'"
|
||||||
|
rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}"
|
||||||
|
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg"
|
||||||
|
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}"
|
||||||
|
rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json"
|
||||||
|
ssh -o BatchMode=yes "${REMOTE_HOST}" \
|
||||||
|
"chmod 644 \
|
||||||
|
'${REMOTE_DIR}/${ZIP_NAME}' \
|
||||||
|
'${REMOTE_DIR}/Redline.dmg' \
|
||||||
|
'${REMOTE_DIR}/${DMG_NAME}' \
|
||||||
|
'${REMOTE_DIR}/appcast.json'"
|
||||||
|
|
||||||
|
echo "==> Verifying public appcast ${APPCAST_URL}"
|
||||||
|
APPCAST_BODY=""
|
||||||
|
ok=0
|
||||||
|
attempt=1
|
||||||
|
while [[ "${attempt}" -le 15 ]]; do
|
||||||
|
if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then
|
||||||
|
echo "${APPCAST_BODY}"
|
||||||
|
if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then
|
||||||
|
echo "OK: appcast contains ${VERSION}"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2
|
||||||
|
else
|
||||||
|
echo "appcast fetch failed (attempt ${attempt})" >&2
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [[ "${ok}" -ne 1 ]]; then
|
||||||
|
echo "Public appcast verification failed for ${APPCAST_URL}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Verifying public zip HEAD ${ZIP_URL}"
|
||||||
|
ok=0
|
||||||
|
attempt=1
|
||||||
|
HEAD_OUT=""
|
||||||
|
while [[ "${attempt}" -le 15 ]]; do
|
||||||
|
HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)"
|
||||||
|
echo "${HEAD_OUT}"
|
||||||
|
HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')"
|
||||||
|
CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)"
|
||||||
|
if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then
|
||||||
|
echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [[ "${ok}" -ne 1 ]]; then
|
||||||
|
echo "Public zip verification failed for ${ZIP_URL}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Published v${VERSION}"
|
||||||
|
echo " appcast: ${APPCAST_URL}"
|
||||||
|
echo " zip: ${ZIP_URL}"
|
||||||
|
echo " sha256: ${SHA256}"
|
||||||
|
echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}"
|
||||||
|
echo " dmg: ${PUBLIC_DIR}/Redline.dmg"
|
||||||
Reference in New Issue
Block a user