2 Commits
Author SHA1 Message Date
Bitsy c8ba0a735c Fix dropped left-clicks via row-level draggable + UI cleanup
The Table's row left-click was racing with SwiftUI's `.draggable`
gesture recognizer on macOS 26 - the gesture watching mouseDown to
decide "is this a drag?" sometimes ate the click even when no drag
followed. Symptom was: right-click selects (bypasses the gesture
through contextMenu(forSelectionType:)), drag works (it's literally
what the gesture handles), but plain left-click drops the row
selection entirely.

Switched ContentView's Table from the implicit-rows form to the
explicit `rows: { ForEach { TableRow(...).draggable(...) } }` form.
Row-level draggable lives alongside NSTableView's row drag machinery
rather than embedded in cell hit-testing, so the click→selection
event chain is no longer interrupted.

Defensive cleanups in the same pass:

  * Extracted the bottom status bar into its own StatusBarView. Its
    @Published refreshes (indexedCount during scans, isIndexing,
    isWorking, etc.) now invalidate only the leaf view rather than
    re-evaluating the ContentView body that contains the Table.

  * Removed the dead `import UniformTypeIdentifiers` from ContentView.

  * Reader-mode FSEvents watcher in AppModel now filters with
    `$0.path == vTarget` exactly, instead of also catching events on
    the parent dir - the latter fired main.async hops for sibling
    files (indexer.lock, .tmp rename leftovers, .DS_Store) that had
    nothing to do with the cache.

  * Cache poll moved to a lock-guarded background BackgroundMtime
    helper so the change detection happens off-main; the main hop
    only fires when the on-disk mtime actually advanced.

  * Column-customization persistence: the JSONEncoder.encode used
    to run synchronously on main inside .onChange, freezing column
    drag (SwiftUI fires onChange on every micro-update). Save is now
    a debounced Task.detached that runs 300 ms after the user lets
    go of the column header.

  * clean.sh detects root-owned /tmp/allofit-service.{log,err} (left
    behind by a previous root daemon) and falls through to `sudo rm`
    instead of failing with "Permission denied". The /tmp sticky bit
    only lets owners delete, so user-mode rm can't touch root logs.

  * Added *.dmg to .gitignore so locally-built distribution
    artifacts don't show up in git status.
2026-06-15 16:18:34 +02:00
Bitsy 1a1235e215 UI + reload-pipeline polish: --version flags, sort binding, click drops
Search bar:
  Search field lives in the body's top row with `.background(.bar)` so
  it always reads as a continuation of the title bar (Liquid Glass on
  macOS 26, vibrant material on macOS 15) and can never be swallowed
  by toolbar overflow. The Settings cogwheel keeps its toolbar slot
  at .primaryAction.

Sort persistence across window close/reopen:
  Replaced the @State sortOrder + onChange dance with a computed
  Binding<[KeyPathComparator]> that reads/writes model.sortDescriptor
  directly. The Table's sortOrder binding now never goes out of sync
  with the persisted model state, and column-header clicks keep
  working after the window is closed and reopened on the still-alive
  process.

Click drops in reader mode:
  Previously the DispatchSource cache-poll posted a main.async every
  2 s and checked mtime on the main side, so the main runloop kept
  picking up an empty task at a fixed rate that could occasionally
  interrupt NSTableView click handling. New BackgroundMtime helper
  (lock-guarded mtime cell) does the change detection off-main; the
  main hop now only happens when the cache actually changed on disk.
  In the steady state the main runloop sees zero recurring work.

makeRecord cache invalidation:
  FileIndexer.makeRecord clears the URL's cached resource values
  before reading so file edits between two FSEvents batches surface
  the new mtime instead of the previous one.

CLI ergonomics:
  build-app.sh and build-dmg.sh both accept --version / -v (and
  build-app.sh accepts --build / -b). The env vars still work as a
  fallback, so the CI release path is unchanged.
2026-06-15 15:42:55 +02:00
7 changed files with 350 additions and 112 deletions
+2 -1
View File
@@ -10,8 +10,9 @@ xcuserdata/
DerivedData/
*.xcuserstate
# Build output produced by ./build-app.sh
# Build output produced by ./build-app.sh and ./build-dmg.sh
Allofit.app/
*.dmg
# Service log files (also live in /tmp but local copies happen during dev)
allofit-service.log
+63 -20
View File
@@ -2,6 +2,27 @@ import Foundation
import SwiftUI
import CoreServices
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
// Lets the DispatchSource timer compare a new stat() result against the
// previously seen value without touching any @MainActor state, so the
// poll only hops to main when the cache has actually changed on disk.
private final class BackgroundMtime: @unchecked Sendable {
private var mtime: Date?
private let lock = NSLock()
// returns true if the supplied mtime differs from the stored value
// (and updates the stored value), false otherwise
func updateIfChanged(_ inMtime: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
if mtime != inMtime {
mtime = inMtime
return true
}
return false
}
}
// AppModel is the central observable state for the application.
// It owns the in-memory file index, drives the background indexer and the
// FSEvents watcher, debounces the search query and exposes a filtered/sorted
@@ -80,8 +101,11 @@ final class AppModel: ObservableObject {
// last user-facing status string for the Settings buttons
@Published private(set) var workMessage: String = ""
// polling backup for reader-mode cache changes; FSEvents alone can miss
// updates if the watched directory didn't exist when the stream started
private var cachePollTimer: Timer?
// updates if the watched directory didn't exist when the stream started.
// Uses DispatchSourceTimer on a background queue (not Timer on the main
// runloop) so the periodic stat() doesn't compete with NSTableView click
// handling - main-runloop timers were the source of dropped clicks.
private var cachePollSource: DispatchSourceTimer?
// last mtime we observed on the cache file - skips needless reloads
private var lastSeenCacheMtime: Date?
// minimum gap between two reader-mode reloads. Prevents the Table from
@@ -124,8 +148,8 @@ final class AppModel: ObservableObject {
cacheWatcher.stop()
autosaveTimer?.invalidate()
autosaveTimer = nil
cachePollTimer?.invalidate()
cachePollTimer = nil
cachePollSource?.cancel()
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
filterTask?.cancel()
@@ -482,29 +506,41 @@ final class AppModel: ObservableObject {
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
// FSEvents-based watcher for low-latency updates
// FSEvents-based watcher for low-latency updates. We watch the
// parent dir (FSEvents needs a real path) but only fire the reload
// when the cache file itself changed - other files in the dir
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
// trigger main.async hops, which contributed to dropped clicks.
cacheWatcher.start(inRoots: [vDir]) { vChanges in
if vChanges.contains(where: { $0.path == vTarget || $0.path == vDir }) {
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
guard vCacheChanged else { return }
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
DispatchQueue.main.async { [weak self] in
self?.reloadFromCache()
}
}
// Background-queue polling backup at 2s. The stat() runs off main,
// and a lock-guarded background-side mtime tracker means we ONLY
// hop to main when the cache actually changed - the steady state
// puts zero work on the main runloop, leaving NSTableView's click
// handling uninterrupted.
cachePollSource?.cancel()
let vPolledPath = vUrl.path
let vBgMtime = BackgroundMtime() // captured by the closure
let vSource = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
vSource.schedule(deadline: .now() + 2.0, repeating: 2.0)
vSource.setEventHandler { [weak self] in
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vPolledPath),
let vMtime = vAttrs[.modificationDate] as? Date
else { return }
// Background-side change detection; only proceeds when mtime moved
guard vBgMtime.updateIfChanged(vMtime) else { return }
DispatchQueue.main.async {
self?.reloadFromCache()
}
// Polling backup at 2s intervals. FSEvents can silently no-op when
// the watched dir didn't exist at stream creation, or under sandbox
// restrictions. The stat() is cheap; reload only fires on mtime change.
// Register on .common runloop mode so the timer keeps firing even
// when SwiftUI is mid-update or the user is interacting with the
// Table - the previous .default mode paused during those windows.
cachePollTimer?.invalidate()
let vTimer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
// Timer closures aren't statically @MainActor; the runtime
// invokes them on whatever runloop hosts the timer (here, main)
Task { @MainActor in self?.pollCacheForChanges() }
}
RunLoop.main.add(vTimer, forMode: .common)
cachePollTimer = vTimer
vSource.resume()
cachePollSource = vSource
}
// stat()'s the cache file and triggers a reload when mtime changes
@@ -626,7 +662,14 @@ final class AppModel: ObservableObject {
// on the next runloop tick, avoiding NSTableView reentrance when
// the search field is mid-edit
DispatchQueue.main.async {
self?.visibleRecords = vCapped
guard let vSelf = self else { return }
// Skip the @Published fire when the resulting list is byte-
// for-byte identical to what the Table is already showing.
// Full FileRecord equality catches mtime / size updates, so
// we only skip true no-op reassignments. Clicks landing on
// the Table during a no-op reload no longer get dropped.
if vSelf.visibleRecords == vCapped { return }
vSelf.visibleRecords = vCapped
}
}
}
+157 -63
View File
@@ -1,69 +1,130 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
// ContentView is the main window layout: search bar at the top, a results
// table that mirrors the look of Everything, and a status bar at the bottom.
// ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table that fills the body, and a status bar at
// the bottom. The Settings gear sits permanently in the window toolbar.
struct ContentView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@State private var selection: Set<FileRecord.ID> = []
// SwiftUI Table sort order - clicking a column header updates this
@State private var sortOrder: [KeyPathComparator<FileRecord>] = [
KeyPathComparator(\FileRecord.name, order: .forward)
]
// drives the Table's drag-to-reorder and column-visibility customization.
// Initial value is hydrated from UserDefaults so the user's column order
// survives app launches; subsequent changes flow back via .onChange.
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization()
// debounced background save task for columnCustomization changes.
// Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>?
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
// Computed binding for the Table's sortOrder: reads/writes
// model.sortDescriptor directly so the sort state survives any number
// of window closes / reopens (the previous `@State sortOrder` got
// reset whenever the view was recreated, and the onChange-syncing
// dance occasionally didn't re-wire properly after a window reopen).
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
Binding(
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
set: { vNewOrder in
guard let vFirst = vNewOrder.first else { return }
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
// defer one runloop tick so we don't write back into the
// model while NSTableView is still in its sort delegate
// callback (avoids the reentrant-operation AppKit warning)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
}
}
)
}
var body: some View {
VStack(spacing: 0) {
searchBar
Divider()
resultsTable
Divider()
statusBar
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
SettingsLink {
Image(systemName: "gearshape")
}
.help("Preferences (⌘,)")
}
}
.onAppear {
// align Table sort order with the persisted sort descriptor
sortOrder = [Self.comparatorFor(inDescriptor: model.sortDescriptor)]
model.start()
}
.onDisappear {
model.saveCache()
}
.onChange(of: sortOrder) { _, vNew in
if let vFirst = vNew.first {
// defer to the next runloop tick so we don't write back into
// the model while NSTableView is still in its sort delegate
// callback - that triggers the "reentrant operation in its
// NSTableView delegate" warning from AppKit
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
DispatchQueue.main.async {
model.sortDescriptor = vDescriptor
.onChange(of: columnCustomization) { _, vNew in
// Debounced + off-main save. SwiftUI fires onChange on every
// micro-update during a column drag - encoding synchronously
// on main here would freeze the drag delegate. Cancel any
// pending task and reschedule so we encode at most once per
// drag (300 ms after the user lets go).
columnSaveTask?.cancel()
columnSaveTask = Task.detached(priority: .utility) {
try? await Task.sleep(nanoseconds: Self.kColumnSaveDebounceNanos)
if Task.isCancelled { return }
ContentView.saveColumnCustomization(vNew)
}
}
}
// ===========================
// MARK: Column customization persistence
// ===========================
// loads the previously-saved column order/visibility from UserDefaults,
// or returns a fresh default if nothing was saved or decoding fails
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode(
TableColumnCustomization<FileRecord>.self,
from: vData
)
else {
return TableColumnCustomization<FileRecord>()
}
return vCustom
}
// persists the current column order/visibility to UserDefaults.
// nonisolated so the debounced background task can call it without an
// actor hop - the encode is the only non-trivial step and we want it
// genuinely off-main during column drags.
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
}
// ===========================
// MARK: Search bar
// ===========================
// Always-visible row at the top. `.background(.bar)` uses the system
// "bar" material, which sits right below the toolbar with the same
// vibrancy treatment - on macOS 26 this is the Liquid Glass surface,
// on macOS 15 it's the standard chrome material.
private var searchBar: some View {
HStack(spacing: 8) {
SearchField(
text: $model.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
.frame(minHeight: 28)
SettingsLink {
Image(systemName: "gearshape")
}
.help("Preferences (⌘,)")
}
.frame(minHeight: 24)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
// ===========================
@@ -71,7 +132,20 @@ struct ContentView: View {
// ===========================
private var resultsTable: some View {
Table(model.visibleRecords, selection: $selection, sortOrder: $sortOrder) {
// Uses the explicit `rows:` form of Table so we can attach `.draggable`
// to TableRow rather than to cell content. Putting `.draggable` on
// cell content installs a SwiftUI drag-gesture recognizer that
// competes with NSTableView's mouseDown → selection event on
// macOS 26 - the recognizer's "should this be a drag?" decision
// delays and occasionally eats the click, leaving the row never
// selected even though right-click (which bypasses the drag gesture
// entirely) still works. Row-level `.draggable` puts the drag at
// the same scope as NSTableView's own row-drag machinery and leaves
// the click path clean.
Table(of: FileRecord.self,
selection: $selection,
sortOrder: sortOrderBinding,
columnCustomization: $columnCustomization) {
TableColumn("Name", value: \FileRecord.name) { vRecord in
HStack(spacing: 6) {
Image(nsImage: IconCache.icon(
@@ -83,9 +157,9 @@ struct ContentView: View {
Text(vRecord.name)
.lineLimit(1)
}
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
.width(min: 200, ideal: 320)
.customizationID("name")
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
Text(vRecord.parentPath)
@@ -94,6 +168,7 @@ struct ContentView: View {
.lineLimit(1)
}
.width(min: 200, ideal: 380)
.customizationID("path")
TableColumn("Size", value: \FileRecord.size) { vRecord in
Text(vRecord.isDirectory ? "—" : Self.formatSize(inBytes: vRecord.size))
@@ -101,6 +176,7 @@ struct ContentView: View {
.monospacedDigit()
}
.width(90)
.customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateCreated))
@@ -108,6 +184,7 @@ struct ContentView: View {
.monospacedDigit()
}
.width(140)
.customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Self.formatDate(inDate: vRecord.dateModified))
@@ -115,6 +192,12 @@ struct ContentView: View {
.monospacedDigit()
}
.width(140)
.customizationID("modified")
} rows: {
ForEach(model.visibleRecords) { vRecord in
TableRow(vRecord)
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
}
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
Button("Open") { openSelection(inIds: vIds) }
@@ -127,37 +210,6 @@ struct ContentView: View {
}
}
// ===========================
// MARK: Status bar
// ===========================
private var statusBar: some View {
HStack(spacing: 8) {
if model.isIndexing {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount) entries")
} else {
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
.foregroundColor(.secondary)
switch prefs.serviceMode {
case .none: EmptyView()
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
}
if !model.query.isEmpty {
Text("· Filtered").foregroundColor(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.font(.caption)
.foregroundColor(.secondary)
}
// ===========================
// MARK: Selection actions
// ===========================
@@ -230,7 +282,7 @@ struct ContentView: View {
return vF
}()
private static func formatSize(inBytes: Int64) -> String {
fileprivate static func formatSize(inBytes: Int64) -> String {
return kSizeFormatter.string(fromByteCount: inBytes)
}
@@ -241,8 +293,50 @@ struct ContentView: View {
return vF
}()
private static func formatDate(inDate: Date) -> String {
fileprivate static func formatDate(inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "—" }
return kDateFormatter.string(from: inDate)
}
}
// ===========================
// MARK: Status bar
// ===========================
// Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small view rather than the ContentView body that
// contains the Table. SwiftUI's dependency tracking is per-View, so an
// isolated leaf observer doesn't churn the Table's closure scope.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
var body: some View {
HStack(spacing: 8) {
if model.isIndexing {
ProgressView()
.controlSize(.small)
Text("Indexing… \(model.indexedCount) entries")
} else {
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
.foregroundColor(.secondary)
switch prefs.serviceMode {
case .none: EmptyView()
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
}
if !model.query.isEmpty {
Text("· Filtered").foregroundColor(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.font(.caption)
.foregroundColor(.secondary)
}
}
+7 -2
View File
@@ -96,9 +96,14 @@ enum FileIndexer {
return vRecords
}
// builds a FileRecord from a URL's pre-fetched resource values
// builds a FileRecord from a URL's pre-fetched resource values.
// Clears the URL's resource-value cache first so we always re-stat the
// file - a file modified between two FSEvents batches would otherwise
// silently return the cached pre-edit mtime.
static func makeRecord(inURL: URL) -> FileRecord? {
guard let vValues = try? inURL.resourceValues(forKeys: Set(kPrefetchKeys)) else {
var vUrl = inURL
vUrl.removeAllCachedResourceValues()
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
return nil
}
let vName = vValues.name ?? inURL.lastPathComponent
+53 -5
View File
@@ -3,7 +3,12 @@
# launched like any other macOS app (double-click in Finder, dragged into
# /Applications, etc.). Run from the project root:
# ./build-app.sh
# The produced bundle ends up at ./Allofit.app
# ./build-app.sh --version 1.0.0 # explicit version stamp
# ./build-app.sh -v 1.0.0 -b 42 # version + build number
# ALLOFIT_VERSION=1.0.0 ./build-app.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# The produced bundle ends up at ./Allofit.app.
#
# Icon support (optional, first match wins):
# icons/Allofit.icns - pre-built .icns, copied straight in
@@ -16,10 +21,53 @@ set -euo pipefail
kAppName="Allofit"
kBundleId="com.bitsycore.allofit"
# version + build can be overridden from CI so the release tag flows into
# the .app's Info.plist. Default to "0.0.0" / "0" for local dev builds.
kVersion="${ALLOFIT_VERSION:-0.0.0}"
kBuildNumber="${ALLOFIT_BUILD:-0}"
# ==================
# MARK: Args
# ==================
vVersionArg=""
vBuildArg=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version|-v)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vVersionArg="$2"
shift 2
;;
--build|-b)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vBuildArg="$2"
shift 2
;;
-h|--help)
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
# version / build precedence: --flag arg > env var > default
if [[ -n "$vVersionArg" ]]; then
kVersion="$vVersionArg"
else
kVersion="${ALLOFIT_VERSION:-0.0.0}"
fi
if [[ -n "$vBuildArg" ]]; then
kBuildNumber="$vBuildArg"
else
kBuildNumber="${ALLOFIT_BUILD:-0}"
fi
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
+44 -10
View File
@@ -4,39 +4,69 @@
# with the .app and an /Applications symlink so the user can drag-install.
#
# Usage:
# ./build-dmg.sh # rebuilds the .app first, then DMGs it
# ./build-dmg.sh --skip-build # assumes Allofit.app already exists
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh
# ./build-dmg.sh # default version (0.0.0)
# ./build-dmg.sh --version 1.0.0 # explicit version
# ./build-dmg.sh -v 1.0.0 --skip-build # version + skip rebuild
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh # env var still works
#
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
# Output: Allofit-<version>.dmg in the project root.
set -euo pipefail
kAppName="Allofit"
kVersion="${ALLOFIT_VERSION:-0.0.0}"
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
vAppBundle="${vProjectRoot}/${kAppName}.app"
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
vStagingDir="${vProjectRoot}/.build/dmg-staging"
# ==================
# MARK: Args
# ==================
vSkipBuild=0
for vArg in "$@"; do
case "$vArg" in
--skip-build) vSkipBuild=1 ;;
vVersionArg=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version|-v)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 1
fi
vVersionArg="$2"
shift 2
;;
--skip-build)
vSkipBuild=1
shift
;;
-h|--help)
sed -n '2,/^set /p' "$0" | sed -E 's/^#( |$)//;/^set /d'
exit 0
;;
*)
echo "Unknown argument: $vArg" >&2
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
# version precedence: --version arg > env var > default
if [[ -n "$vVersionArg" ]]; then
kVersion="$vVersionArg"
else
kVersion="${ALLOFIT_VERSION:-0.0.0}"
fi
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
# ==================
# MARK: Build .app
# ==================
if [[ "$vSkipBuild" -eq 0 ]]; then
echo "==> Rebuilding ${kAppName}.app first"
"${vProjectRoot}/build-app.sh"
# Propagate the resolved version into build-app.sh so the bundled
# Info.plist matches what we're naming the DMG.
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/build-app.sh"
fi
if [[ ! -d "${vAppBundle}" ]]; then
@@ -44,6 +74,10 @@ if [[ ! -d "${vAppBundle}" ]]; then
exit 1
fi
# ==================
# MARK: Stage + create DMG
# ==================
echo "==> Staging DMG contents"
rm -rf "${vStagingDir}"
mkdir -p "${vStagingDir}"
+14 -1
View File
@@ -147,7 +147,20 @@ fi
if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
echo "==> Removing service log files"
vRun rm -f "$kServiceLogStdout" "$kServiceLogStderr"
# /tmp has the sticky bit, so only the file's owner can rm it. Logs
# left over from a previous *root* daemon run are owned by root - we
# need sudo to delete them. Plain rm for user-owned logs (user agent
# mode), sudo rm for root-owned ones.
for vLog in "$kServiceLogStdout" "$kServiceLogStderr"; do
if [[ -f "$vLog" ]]; then
if [[ -O "$vLog" ]]; then
vRun rm -f "$vLog"
else
vNeedsSudo
vRun sudo rm -f "$vLog"
fi
fi
done
fi
# ==================