mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ba0a735c | ||
|
|
1a1235e215 |
+2
-1
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.reloadFromCache()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
// 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() }
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (⌘,)")
|
||||
}
|
||||
SearchField(
|
||||
text: $model.query,
|
||||
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
||||
initiallyFirstResponder: true
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
# ==================
|
||||
|
||||
Reference in New Issue
Block a user