Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7284568489 | ||
|
|
8d66e49e07 | ||
|
|
e253a966ab | ||
|
|
fa3e7b0a4a |
@@ -1,5 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Carbon.HIToolbox
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -37,6 +36,9 @@ public final class AppModel {
|
|||||||
public private(set) var outboxURL: URL
|
public private(set) var outboxURL: URL
|
||||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||||
public private(set) var watchFolderURL: URL
|
public private(set) var watchFolderURL: URL
|
||||||
|
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
|
||||||
|
private(set) var captureHotkey: HotkeyPreference
|
||||||
|
var hotkeyDisplayString: String { captureHotkey.displayString }
|
||||||
|
|
||||||
let paths: AppSupportPaths
|
let paths: AppSupportPaths
|
||||||
let spool: SpoolStore
|
let spool: SpoolStore
|
||||||
@@ -80,6 +82,7 @@ public final class AppModel {
|
|||||||
self.watchFolderURL = folders.watch
|
self.watchFolderURL = folders.watch
|
||||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||||
|
self.captureHotkey = HotkeyPreference.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -146,16 +149,39 @@ public final class AppModel {
|
|||||||
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
|
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let registered = hotkeys.register(
|
let pref = HotkeyPreference.load()
|
||||||
|
captureHotkey = pref
|
||||||
|
if !bindCaptureHotkey(pref) {
|
||||||
|
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
|
||||||
|
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
|
||||||
|
func reRegisterHotkey() {
|
||||||
|
let previous = captureHotkey
|
||||||
|
let next = HotkeyPreference.load()
|
||||||
|
hotkeys.unregister(id: "capture")
|
||||||
|
if bindCaptureHotkey(next) {
|
||||||
|
captureHotkey = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus("That combination is taken — pick another.")
|
||||||
|
previous.save()
|
||||||
|
if bindCaptureHotkey(previous) {
|
||||||
|
captureHotkey = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func bindCaptureHotkey(_ pref: HotkeyPreference) -> Bool {
|
||||||
|
hotkeys.register(
|
||||||
id: "capture",
|
id: "capture",
|
||||||
keyCode: UInt32(kVK_ANSI_2),
|
keyCode: pref.keyCode,
|
||||||
modifiers: UInt32(optionKey | shiftKey)
|
modifiers: pref.modifiers
|
||||||
) { [weak self] in
|
) { [weak self] in
|
||||||
Task { await self?.captureNow() }
|
Task { await self?.captureNow() }
|
||||||
}
|
}
|
||||||
if !registered {
|
|
||||||
setStatus("⌥⇧2 is already used by another app — capture only works from the menu.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func captureNow() async {
|
public func captureNow() async {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// User-chosen capture hotkey. `modifiers` are Carbon bits (`cmdKey`, `optionKey`,
|
||||||
|
/// `shiftKey`, `controlKey`), matching `HotkeyCenter.register`.
|
||||||
|
struct HotkeyPreference: Codable, Equatable, Sendable {
|
||||||
|
var keyCode: UInt32
|
||||||
|
var modifiers: UInt32
|
||||||
|
|
||||||
|
static let defaultsKey = "ai.flowmaster.shotdeck.hotkey"
|
||||||
|
|
||||||
|
/// ⌥⇧2 — kVK_ANSI_2 (19) with optionKey|shiftKey.
|
||||||
|
static let `default` = HotkeyPreference(
|
||||||
|
keyCode: 19,
|
||||||
|
modifiers: UInt32(optionKey) | UInt32(shiftKey)
|
||||||
|
)
|
||||||
|
|
||||||
|
static func load(defaults: UserDefaults = .standard) -> HotkeyPreference {
|
||||||
|
guard let data = defaults.data(forKey: defaultsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(HotkeyPreference.self, from: data),
|
||||||
|
decoded.modifiers != 0
|
||||||
|
else { return .default }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(defaults: UserDefaults = .standard) {
|
||||||
|
guard let data = try? JSONEncoder().encode(self) else { return }
|
||||||
|
defaults.set(data, forKey: Self.defaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌘⌥⇧⌃ in that order, then a name for common keycodes.
|
||||||
|
var displayString: String {
|
||||||
|
var s = ""
|
||||||
|
if modifiers & UInt32(cmdKey) != 0 { s += "⌘" }
|
||||||
|
if modifiers & UInt32(optionKey) != 0 { s += "⌥" }
|
||||||
|
if modifiers & UInt32(shiftKey) != 0 { s += "⇧" }
|
||||||
|
if modifiers & UInt32(controlKey) != 0 { s += "⌃" }
|
||||||
|
s += Self.keyName(for: keyCode)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` when the event is modifier-only or has no ⌘⌥⇧⌃ flags.
|
||||||
|
static func fromKeyEvent(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> HotkeyPreference? {
|
||||||
|
switch Int(keyCode) {
|
||||||
|
case kVK_Shift, kVK_RightShift,
|
||||||
|
kVK_Command, kVK_RightCommand,
|
||||||
|
kVK_Option, kVK_RightOption,
|
||||||
|
kVK_Control, kVK_RightControl,
|
||||||
|
kVK_CapsLock, kVK_Function:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var carbon: UInt32 = 0
|
||||||
|
if modifierFlags.contains(.command) { carbon |= UInt32(cmdKey) }
|
||||||
|
if modifierFlags.contains(.option) { carbon |= UInt32(optionKey) }
|
||||||
|
if modifierFlags.contains(.shift) { carbon |= UInt32(shiftKey) }
|
||||||
|
if modifierFlags.contains(.control) { carbon |= UInt32(controlKey) }
|
||||||
|
guard carbon != 0 else { return nil }
|
||||||
|
return HotkeyPreference(keyCode: UInt32(keyCode), modifiers: carbon)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func keyName(for keyCode: UInt32) -> String {
|
||||||
|
switch Int(keyCode) {
|
||||||
|
case kVK_ANSI_A: return "A"
|
||||||
|
case kVK_ANSI_B: return "B"
|
||||||
|
case kVK_ANSI_C: return "C"
|
||||||
|
case kVK_ANSI_D: return "D"
|
||||||
|
case kVK_ANSI_E: return "E"
|
||||||
|
case kVK_ANSI_F: return "F"
|
||||||
|
case kVK_ANSI_G: return "G"
|
||||||
|
case kVK_ANSI_H: return "H"
|
||||||
|
case kVK_ANSI_I: return "I"
|
||||||
|
case kVK_ANSI_J: return "J"
|
||||||
|
case kVK_ANSI_K: return "K"
|
||||||
|
case kVK_ANSI_L: return "L"
|
||||||
|
case kVK_ANSI_M: return "M"
|
||||||
|
case kVK_ANSI_N: return "N"
|
||||||
|
case kVK_ANSI_O: return "O"
|
||||||
|
case kVK_ANSI_P: return "P"
|
||||||
|
case kVK_ANSI_Q: return "Q"
|
||||||
|
case kVK_ANSI_R: return "R"
|
||||||
|
case kVK_ANSI_S: return "S"
|
||||||
|
case kVK_ANSI_T: return "T"
|
||||||
|
case kVK_ANSI_U: return "U"
|
||||||
|
case kVK_ANSI_V: return "V"
|
||||||
|
case kVK_ANSI_W: return "W"
|
||||||
|
case kVK_ANSI_X: return "X"
|
||||||
|
case kVK_ANSI_Y: return "Y"
|
||||||
|
case kVK_ANSI_Z: return "Z"
|
||||||
|
case kVK_ANSI_0: return "0"
|
||||||
|
case kVK_ANSI_1: return "1"
|
||||||
|
case kVK_ANSI_2: return "2"
|
||||||
|
case kVK_ANSI_3: return "3"
|
||||||
|
case kVK_ANSI_4: return "4"
|
||||||
|
case kVK_ANSI_5: return "5"
|
||||||
|
case kVK_ANSI_6: return "6"
|
||||||
|
case kVK_ANSI_7: return "7"
|
||||||
|
case kVK_ANSI_8: return "8"
|
||||||
|
case kVK_ANSI_9: return "9"
|
||||||
|
case kVK_ANSI_Equal: return "="
|
||||||
|
case kVK_ANSI_Minus: return "-"
|
||||||
|
case kVK_ANSI_RightBracket: return "]"
|
||||||
|
case kVK_ANSI_LeftBracket: return "["
|
||||||
|
case kVK_ANSI_Quote: return "'"
|
||||||
|
case kVK_ANSI_Semicolon: return ";"
|
||||||
|
case kVK_ANSI_Backslash: return "\\"
|
||||||
|
case kVK_ANSI_Comma: return ","
|
||||||
|
case kVK_ANSI_Slash: return "/"
|
||||||
|
case kVK_ANSI_Period: return "."
|
||||||
|
case kVK_ANSI_Grave: return "`"
|
||||||
|
case kVK_Space: return "Space"
|
||||||
|
case kVK_Return: return "Return"
|
||||||
|
case kVK_Tab: return "Tab"
|
||||||
|
case kVK_Delete: return "Delete"
|
||||||
|
case kVK_ForwardDelete: return "Fwd Delete"
|
||||||
|
case kVK_Escape: return "Esc"
|
||||||
|
case kVK_Home: return "Home"
|
||||||
|
case kVK_End: return "End"
|
||||||
|
case kVK_PageUp: return "Page Up"
|
||||||
|
case kVK_PageDown: return "Page Down"
|
||||||
|
case kVK_Help: return "Help"
|
||||||
|
case kVK_LeftArrow: return "←"
|
||||||
|
case kVK_RightArrow: return "→"
|
||||||
|
case kVK_DownArrow: return "↓"
|
||||||
|
case kVK_UpArrow: return "↑"
|
||||||
|
case kVK_F1: return "F1"
|
||||||
|
case kVK_F2: return "F2"
|
||||||
|
case kVK_F3: return "F3"
|
||||||
|
case kVK_F4: return "F4"
|
||||||
|
case kVK_F5: return "F5"
|
||||||
|
case kVK_F6: return "F6"
|
||||||
|
case kVK_F7: return "F7"
|
||||||
|
case kVK_F8: return "F8"
|
||||||
|
case kVK_F9: return "F9"
|
||||||
|
case kVK_F10: return "F10"
|
||||||
|
case kVK_F11: return "F11"
|
||||||
|
case kVK_F12: return "F12"
|
||||||
|
case kVK_F13: return "F13"
|
||||||
|
case kVK_F14: return "F14"
|
||||||
|
case kVK_F15: return "F15"
|
||||||
|
case kVK_F16: return "F16"
|
||||||
|
case kVK_F17: return "F17"
|
||||||
|
case kVK_F18: return "F18"
|
||||||
|
case kVK_F19: return "F19"
|
||||||
|
case kVK_F20: return "F20"
|
||||||
|
default: return "Key \(keyCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ struct MenuBarView: View {
|
|||||||
|
|
||||||
private var defaultStatusText: String {
|
private var defaultStatusText: String {
|
||||||
guard let region = model.region else {
|
guard let region = model.region else {
|
||||||
return "No region yet — press ⌥⇧2 to pick one."
|
return "No region yet — press \(model.hotkeyDisplayString) to pick one."
|
||||||
}
|
}
|
||||||
let w = Int(region.rect.width)
|
let w = Int(region.rect.width)
|
||||||
let h = Int(region.rect.height)
|
let h = Int(region.rect.height)
|
||||||
@@ -89,7 +89,7 @@ struct MenuBarView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await model.captureNow() }
|
Task { await model.captureNow() }
|
||||||
} label: {
|
} label: {
|
||||||
actionLabel("Capture now", trailing: "⌥⇧2")
|
actionLabel("Capture now", trailing: model.hotkeyDisplayString)
|
||||||
}
|
}
|
||||||
.disabled(model.isCapturing)
|
.disabled(model.isCapturing)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import ShotdeckCore
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(AppModel.self) private var model
|
@Environment(AppModel.self) private var model
|
||||||
|
@State private var isRecordingHotkey = false
|
||||||
|
@State private var recorder = HotkeyRecorderBox()
|
||||||
|
|
||||||
private let labelWidth: CGFloat = 104
|
private let labelWidth: CGFloat = 104
|
||||||
|
|
||||||
@@ -16,13 +18,19 @@ struct SettingsView: View {
|
|||||||
.gridCellColumns(2)
|
.gridCellColumns(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow(alignment: .firstTextBaseline) {
|
GridRow(alignment: .center) {
|
||||||
fieldLabel("Capture")
|
fieldLabel("Capture")
|
||||||
Text("⌥⇧2 — fixed in this version")
|
HStack(spacing: 8) {
|
||||||
.foregroundStyle(.secondary)
|
Button {
|
||||||
.lineLimit(1)
|
armHotkeyRecorder()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
} label: {
|
||||||
.frame(minHeight: 22, alignment: .leading)
|
Text(isRecordingHotkey ? "Press keys…" : model.hotkeyDisplayString)
|
||||||
|
.foregroundStyle(isRecordingHotkey ? .secondary : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 22)
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
@@ -57,6 +65,35 @@ struct SettingsView: View {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
.frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
|
.frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
.onDisappear { disarmHotkeyRecorder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func armHotkeyRecorder() {
|
||||||
|
guard !isRecordingHotkey else { return }
|
||||||
|
isRecordingHotkey = true
|
||||||
|
recorder.onKey = { keyCode, flags in
|
||||||
|
handleRecorderKey(keyCode: keyCode, flags: flags)
|
||||||
|
}
|
||||||
|
recorder.arm()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecorderKey(keyCode: UInt16, flags: NSEvent.ModifierFlags) {
|
||||||
|
if keyCode == 53 { // kVK_Escape
|
||||||
|
disarmHotkeyRecorder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let pref = HotkeyPreference.fromKeyEvent(keyCode: keyCode, modifierFlags: flags) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pref.save()
|
||||||
|
model.reRegisterHotkey()
|
||||||
|
disarmHotkeyRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disarmHotkeyRecorder() {
|
||||||
|
recorder.disarm()
|
||||||
|
recorder.onKey = nil
|
||||||
|
isRecordingHotkey = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fieldLabel(_ title: String) -> some View {
|
private func fieldLabel(_ title: String) -> some View {
|
||||||
@@ -80,6 +117,35 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local keyDown monitor for the Settings capture-hotkey recorder. Callbacks hop onto the
|
||||||
|
/// main actor the same way `RegionPickerController` does — local monitors fire on the
|
||||||
|
/// main run loop during `NSApp.sendEvent`.
|
||||||
|
@MainActor
|
||||||
|
private final class HotkeyRecorderBox {
|
||||||
|
var onKey: ((UInt16, NSEvent.ModifierFlags) -> Void)?
|
||||||
|
private var monitor: Any?
|
||||||
|
|
||||||
|
func arm() {
|
||||||
|
disarm()
|
||||||
|
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
guard let self else { return event }
|
||||||
|
let keyCode = event.keyCode
|
||||||
|
let rawFlags = event.modifierFlags.rawValue
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
self.onKey?(keyCode, NSEvent.ModifierFlags(rawValue: rawFlags))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disarm() {
|
||||||
|
if let monitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
}
|
||||||
|
monitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension AppModel: SettingsWindowPresenting {
|
extension AppModel: SettingsWindowPresenting {
|
||||||
private static var settingsWindowController: NSWindowController?
|
private static var settingsWindowController: NSWindowController?
|
||||||
|
|
||||||
|
|||||||
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