Add SHA256-based auto-update mechanism
- New `make release` target generates Pommedoro.dmg.sha256 alongside the DMG - AutoUpdater.swift checks the remote SHA256 against the local stamp on launch and prompts the user to update when a mismatch is detected - install.command stamps the SHA256 of the source DMG at install time - "Check for Updates…" menu item (Cmd+U) for manual checks - Fix break screen close buttons to resume instead of showing work log - Migrate worklog.log storage to ~/Library/Application Support/Pommedoro/ Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0d2ef2eedc
commit
129e839f8d
10
Makefile
10
Makefile
|
|
@ -9,7 +9,7 @@ RESOURCES_DIR = $(CONTENTS)/Resources
|
||||||
LAUNCH_AGENT_LABEL = com.pommedoro.app
|
LAUNCH_AGENT_LABEL = com.pommedoro.app
|
||||||
LAUNCH_AGENT_PLIST = $(HOME)/Library/LaunchAgents/$(LAUNCH_AGENT_LABEL).plist
|
LAUNCH_AGENT_PLIST = $(HOME)/Library/LaunchAgents/$(LAUNCH_AGENT_LABEL).plist
|
||||||
|
|
||||||
.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg
|
.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg release
|
||||||
|
|
||||||
all: bundle
|
all: bundle
|
||||||
|
|
||||||
|
|
@ -103,3 +103,11 @@ dmg: bundle
|
||||||
clean:
|
clean:
|
||||||
swift package clean
|
swift package clean
|
||||||
rm -rf $(APP_BUNDLE)
|
rm -rf $(APP_BUNDLE)
|
||||||
|
|
||||||
|
release: dmg
|
||||||
|
@echo "Creating release artifacts..."
|
||||||
|
@mkdir -p releases
|
||||||
|
@cp $(BUILD_DIR)/$(APP_NAME).dmg releases/$(APP_NAME).dmg
|
||||||
|
@shasum -a 256 releases/$(APP_NAME).dmg | awk '{print $$1}' > releases/$(APP_NAME).dmg.sha256
|
||||||
|
@echo "SHA256: $$(cat releases/$(APP_NAME).dmg.sha256)"
|
||||||
|
@echo "Release artifacts in releases/"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@ cp -R "${APP_SRC}" "${APP_DST}"
|
||||||
# Strip quarantine attribute (fixes "app is damaged" on downloaded DMGs)
|
# Strip quarantine attribute (fixes "app is damaged" on downloaded DMGs)
|
||||||
xattr -cr "${APP_DST}"
|
xattr -cr "${APP_DST}"
|
||||||
|
|
||||||
|
# Stamp SHA256 so auto-updater knows current version
|
||||||
|
SHA_DIR="${HOME}/Library/Application Support/Pommedoro"
|
||||||
|
mkdir -p "${SHA_DIR}"
|
||||||
|
# Find the source .dmg backing this mounted volume
|
||||||
|
DMG_DEVICE="$(hdiutil info 2>/dev/null | grep -B 20 "${SCRIPT_DIR}" | grep 'image-path' | awk -F' : ' '{print $2}' | head -1)"
|
||||||
|
if [ -n "${DMG_DEVICE}" ] && [ -f "${DMG_DEVICE}" ]; then
|
||||||
|
shasum -a 256 "${DMG_DEVICE}" | awk '{print $1}' > "${SHA_DIR}/current.sha256"
|
||||||
|
echo " SHA256 stamped for auto-update."
|
||||||
|
fi
|
||||||
|
|
||||||
echo " Installed to ${APP_DST}"
|
echo " Installed to ${APP_DST}"
|
||||||
echo " Launching ${APP_NAME}..."
|
echo " Launching ${APP_NAME}..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
NotificationManager.shared.requestPermission()
|
NotificationManager.shared.requestPermission()
|
||||||
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
|
||||||
|
// Check for updates silently on launch
|
||||||
|
AutoUpdater.checkInBackground(silent: true)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(settingsDidChange),
|
self, selector: #selector(settingsDidChange),
|
||||||
name: Settings.didChangeNotification, object: nil
|
name: Settings.didChangeNotification, object: nil
|
||||||
|
|
@ -181,6 +184,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@objc func checkForUpdates() {
|
||||||
|
AutoUpdater.checkInBackground(silent: false)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func closePommedoro() {
|
@objc func closePommedoro() {
|
||||||
saveCurrentInterval()
|
saveCurrentInterval()
|
||||||
countdownTimer?.invalidate()
|
countdownTimer?.invalidate()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import CommonCrypto
|
||||||
|
|
||||||
|
/// Handles automatic self-update by comparing the local DMG SHA256 against
|
||||||
|
/// the one published in the repository. When a mismatch is detected the new
|
||||||
|
/// DMG is downloaded, mounted, and the app is reinstalled + relaunched.
|
||||||
|
enum AutoUpdater {
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Raw URL for the SHA256 file in the repo (Gitea raw endpoint)
|
||||||
|
private static let remoteSHA256URL = URL(
|
||||||
|
string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg.sha256"
|
||||||
|
)!
|
||||||
|
|
||||||
|
/// Raw URL for the DMG itself
|
||||||
|
private static let remoteDMGURL = URL(
|
||||||
|
string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg"
|
||||||
|
)!
|
||||||
|
|
||||||
|
/// Where to store the local copy of the last-known SHA256
|
||||||
|
private static var localSHAPath: URL {
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
let dir = support.appendingPathComponent("Pommedoro", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("current.sha256")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporary download location for the DMG
|
||||||
|
private static var downloadedDMGPath: URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Check for updates in the background. Optionally show UI feedback.
|
||||||
|
static func checkInBackground(silent: Bool = true) {
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
self.performCheck(silent: silent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Logic
|
||||||
|
|
||||||
|
private static func performCheck(silent: Bool) {
|
||||||
|
// 1. Fetch remote SHA256
|
||||||
|
guard let remoteSHA = fetchRemoteSHA() else {
|
||||||
|
if !silent { showAlert(title: "Update Check Failed", message: "Could not reach the update server.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read local SHA256 (written at last install/build)
|
||||||
|
let localSHA = readLocalSHA()
|
||||||
|
|
||||||
|
// 3. Compare
|
||||||
|
if remoteSHA == localSHA {
|
||||||
|
if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. There's an update – prompt user
|
||||||
|
let shouldUpdate = promptForUpdate()
|
||||||
|
guard shouldUpdate else { return }
|
||||||
|
|
||||||
|
// 5. Download new DMG
|
||||||
|
guard downloadDMG() else {
|
||||||
|
showAlert(title: "Update Failed", message: "Could not download the update.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify downloaded DMG matches remote SHA
|
||||||
|
guard let downloadedSHA = sha256(of: downloadedDMGPath), downloadedSHA == remoteSHA else {
|
||||||
|
showAlert(title: "Update Failed", message: "Downloaded file integrity check failed.")
|
||||||
|
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Mount, install, relaunch
|
||||||
|
guard installFromDMG() else {
|
||||||
|
showAlert(title: "Update Failed", message: "Could not install the update.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Save new SHA so we don't re-update next launch
|
||||||
|
try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
// 9. Relaunch
|
||||||
|
relaunch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Networking
|
||||||
|
|
||||||
|
private static func fetchRemoteSHA() -> String? {
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var result: String?
|
||||||
|
|
||||||
|
let task = URLSession.shared.dataTask(with: remoteSHA256URL) { data, response, _ in
|
||||||
|
defer { sem.signal() }
|
||||||
|
guard let data = data,
|
||||||
|
let http = response as? HTTPURLResponse,
|
||||||
|
http.statusCode == 200,
|
||||||
|
let str = String(data: data, encoding: .utf8) else { return }
|
||||||
|
result = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
sem.wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func downloadDMG() -> Bool {
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var success = false
|
||||||
|
|
||||||
|
let task = URLSession.shared.downloadTask(with: remoteDMGURL) { url, response, _ in
|
||||||
|
defer { sem.signal() }
|
||||||
|
guard let url = url,
|
||||||
|
let http = response as? HTTPURLResponse,
|
||||||
|
http.statusCode == 200 else { return }
|
||||||
|
do {
|
||||||
|
let dest = downloadedDMGPath
|
||||||
|
try? FileManager.default.removeItem(at: dest)
|
||||||
|
try FileManager.default.moveItem(at: url, to: dest)
|
||||||
|
success = true
|
||||||
|
} catch {
|
||||||
|
BugReporting.capture(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
sem.wait()
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SHA256 Computation
|
||||||
|
|
||||||
|
private static func sha256(of fileURL: URL) -> String? {
|
||||||
|
guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil }
|
||||||
|
defer { handle.closeFile() }
|
||||||
|
|
||||||
|
var ctx = CC_SHA256_CTX()
|
||||||
|
CC_SHA256_Init(&ctx)
|
||||||
|
|
||||||
|
while autoreleasepool(invoking: {
|
||||||
|
let chunk = handle.readData(ofLength: 1_048_576) // 1 MB
|
||||||
|
if chunk.isEmpty { return false }
|
||||||
|
chunk.withUnsafeBytes { ptr in
|
||||||
|
_ = CC_SHA256_Update(&ctx, ptr.baseAddress, CC_LONG(chunk.count))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||||
|
CC_SHA256_Final(&digest, &ctx)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local SHA
|
||||||
|
|
||||||
|
private static func readLocalSHA() -> String? {
|
||||||
|
guard let str = try? String(contentsOf: localSHAPath, encoding: .utf8) else { return nil }
|
||||||
|
return str.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the current SHA256 so subsequent update checks know the baseline.
|
||||||
|
static func writeLocalSHA(_ sha: String) {
|
||||||
|
try? sha.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DMG Install
|
||||||
|
|
||||||
|
private static func installFromDMG() -> Bool {
|
||||||
|
let dmg = downloadedDMGPath.path
|
||||||
|
let mountPoint = "/tmp/pommedoro-update-mount"
|
||||||
|
|
||||||
|
// Mount
|
||||||
|
let mountOK = runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet")
|
||||||
|
guard mountOK else { return false }
|
||||||
|
|
||||||
|
defer {
|
||||||
|
_ = runShell("hdiutil detach \"\(mountPoint)\" -quiet")
|
||||||
|
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
let srcApp = "\(mountPoint)/Pommedoro.app"
|
||||||
|
let dstApp = "/Applications/Pommedoro.app"
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
||||||
|
|
||||||
|
// Remove old and copy new
|
||||||
|
try? FileManager.default.removeItem(atPath: dstApp)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(atPath: srcApp, toPath: dstApp)
|
||||||
|
} catch {
|
||||||
|
BugReporting.capture(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip quarantine
|
||||||
|
_ = runShell("xattr -cr \"\(dstApp)\"")
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shell Helper
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private static func runShell(_ command: String) -> Bool {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||||
|
proc.arguments = ["-c", command]
|
||||||
|
proc.standardOutput = FileHandle.nullDevice
|
||||||
|
proc.standardError = FileHandle.nullDevice
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
return proc.terminationStatus == 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relaunch
|
||||||
|
|
||||||
|
private static func relaunch() {
|
||||||
|
let appPath = "/Applications/Pommedoro.app"
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||||
|
task.arguments = ["-n", appPath]
|
||||||
|
try? task.run()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Prompts
|
||||||
|
|
||||||
|
private static func promptForUpdate() -> Bool {
|
||||||
|
var result = false
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Update Available"
|
||||||
|
alert.informativeText = "A new version of Pommedoro is available. Would you like to update now?"
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.addButton(withTitle: "Update")
|
||||||
|
alert.addButton(withTitle: "Later")
|
||||||
|
result = alert.runModal() == .alertFirstButtonReturn
|
||||||
|
sem.signal()
|
||||||
|
}
|
||||||
|
sem.wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func showAlert(title: String, message: String) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(showWorkLog)
|
closeBtn.action = #selector(resumePommedoro)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
|
let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50))
|
||||||
|
|
@ -198,7 +198,7 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(showWorkLog)
|
closeBtn.action = #selector(resumePommedoro)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
let reflY = size.height / 2 - 60
|
let reflY = size.height / 2 - 60
|
||||||
|
|
@ -282,7 +282,7 @@ extension AppDelegate {
|
||||||
closeBtn.bezelStyle = .rounded
|
closeBtn.bezelStyle = .rounded
|
||||||
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
closeBtn.target = self
|
closeBtn.target = self
|
||||||
closeBtn.action = #selector(showWorkLog)
|
closeBtn.action = #selector(resumePommedoro)
|
||||||
view.addSubview(closeBtn)
|
view.addSubview(closeBtn)
|
||||||
|
|
||||||
// Reflection text field
|
// Reflection text field
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ extension AppDelegate {
|
||||||
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
|
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
|
||||||
menu.addItem(launchAtLoginMenuItem)
|
menu.addItem(launchAtLoginMenuItem)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "u"))
|
||||||
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
||||||
|
|
||||||
for item in menu.items {
|
for item in menu.items {
|
||||||
|
|
|
||||||
|
|
@ -68,15 +68,46 @@ class WorkLogStore {
|
||||||
private(set) var entries: [WorkLogEntry] = []
|
private(set) var entries: [WorkLogEntry] = []
|
||||||
|
|
||||||
private static var workLogFileURL: URL {
|
private static var workLogFileURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
.appendingPathComponent("Documents", isDirectory: true)
|
return support
|
||||||
.appendingPathComponent("pommedoro", isDirectory: true)
|
.appendingPathComponent("Pommedoro", isDirectory: true)
|
||||||
.appendingPathComponent("worklog.log", isDirectory: false)
|
.appendingPathComponent("worklog.log", isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
load()
|
load()
|
||||||
clearOldEntries()
|
clearOldEntries()
|
||||||
|
migrateFromDocumentsIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time migration: move worklog.log from ~/Documents/pommedoro/ to Application Support
|
||||||
|
private func migrateFromDocumentsIfNeeded() {
|
||||||
|
let oldFile = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Documents", isDirectory: true)
|
||||||
|
.appendingPathComponent("pommedoro", isDirectory: true)
|
||||||
|
.appendingPathComponent("worklog.log", isDirectory: false)
|
||||||
|
guard FileManager.default.fileExists(atPath: oldFile.path) else { return }
|
||||||
|
|
||||||
|
ensureWorkLogDirectory()
|
||||||
|
let newFile = Self.workLogFileURL
|
||||||
|
|
||||||
|
// If new file doesn't exist yet, just move it; otherwise append old content
|
||||||
|
if !FileManager.default.fileExists(atPath: newFile.path) {
|
||||||
|
try? FileManager.default.moveItem(at: oldFile, to: newFile)
|
||||||
|
} else if let oldData = try? Data(contentsOf: oldFile),
|
||||||
|
let handle = try? FileHandle(forWritingTo: newFile) {
|
||||||
|
handle.seekToEndOfFile()
|
||||||
|
handle.write(oldData)
|
||||||
|
try? handle.close()
|
||||||
|
try? FileManager.default.removeItem(at: oldFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty old directory
|
||||||
|
let oldDir = oldFile.deletingLastPathComponent()
|
||||||
|
let remaining = (try? FileManager.default.contentsOfDirectory(atPath: oldDir.path)) ?? []
|
||||||
|
if remaining.isEmpty {
|
||||||
|
try? FileManager.default.removeItem(at: oldDir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntry(_ entry: WorkLogEntry) {
|
func addEntry(_ entry: WorkLogEntry) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue