feat: added an option to sync clock with ntp server and moved reader to separate file

This commit is contained in:
Serhiy Mytrovtsiy
2026-03-11 18:06:52 +01:00
parent b94207c49f
commit 510d9132a6
4 changed files with 155 additions and 22 deletions

View File

@@ -23,20 +23,12 @@ public struct Clock_t: Codable {
public var value: Date? = nil public var value: Date? = nil
var popupIndex: Int { var popupIndex: Int {
get { get { Store.shared.int(key: "clock_\(self.id)_popupIndex", defaultValue: -1) }
Store.shared.int(key: "clock_\(self.id)_popupIndex", defaultValue: -1) set { Store.shared.set(key: "clock_\(self.id)_popupIndex", value: newValue) }
}
set {
Store.shared.set(key: "clock_\(self.id)_popupIndex", value: newValue)
}
} }
var popupState: Bool { var popupState: Bool {
get { get { Store.shared.bool(key: "clock_\(self.id)_popupState", defaultValue: true) }
Store.shared.bool(key: "clock_\(self.id)_popupState", defaultValue: true) set { Store.shared.set(key: "clock_\(self.id)_popupState", value: newValue) }
}
set {
Store.shared.set(key: "clock_\(self.id)_popupState", value: newValue)
}
} }
public func formatted() -> String { public func formatted() -> String {
@@ -47,12 +39,6 @@ public struct Clock_t: Codable {
} }
} }
internal class ClockReader: Reader<Date> {
public override func read() {
self.callback(Date())
}
}
public class Clock: Module { public class Clock: Module {
private let popupView: Popup = Popup(.clock) private let popupView: Popup = Popup(.clock)
private let portalView: Portal private let portalView: Portal
@@ -85,6 +71,12 @@ public class Clock: Module {
self?.callback(value) self?.callback(value)
} }
self.settingsView.callback = { [weak self] in
guard let self, self.enabled, let reader = self.reader else { return }
reader.stop()
reader.start()
}
self.setReaders([self.reader]) self.setReaders([self.reader])
} }

123
Modules/Clock/reader.swift Normal file
View File

@@ -0,0 +1,123 @@
//
// reader.swift
// Stats
//
// Created by Serhiy Mytrovtsiy on 05/03/2026
// Using Swift 6.0
// Running on macOS 26.3
//
// Copyright © 2026 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
import Kit
internal class ClockReader: Reader<Date> {
private let title: String = ModuleType.clock.stringValue
private let queue = DispatchQueue(label: "eu.exelban.Stats.Clock.ntp.sync", qos: .default)
private var _offset: TimeInterval = 0
private var offset: TimeInterval {
get { self.queue.sync { self._offset } }
set { self.queue.sync { self._offset = newValue } }
}
private var now: Date { Date().addingTimeInterval(self.offset) }
private var ntpSync: Bool {
get { Store.shared.bool(key: "\(self.title)_ntpSync", defaultValue: false) }
set { Store.shared.set(key: "\(self.title)_ntpSync", value: newValue) }
}
private var ntpServer: String {
get { Store.shared.string(key: "\(self.title)_ntpServer", defaultValue: "pool.ntp.org") }
set { Store.shared.set(key: "\(self.title)_ntpServer", value: newValue) }
}
public override func setup() {
self.syncWithNTP()
}
public override func read() {
let date = self.ntpSync ? self.now : Date()
self.callback(date)
if Calendar.current.component(.second, from: date) == 0 {
self.syncWithNTP()
}
}
private func syncWithNTP() {
guard self.ntpSync else {
self.offset = 0
return
}
let server = self.ntpServer
self.queue.async { [weak self] in
guard let self else { return }
guard let serverDate = self.requestTime(server: server) else { return }
let newOffset = serverDate.timeIntervalSince(Date())
self._offset = newOffset
self.alignOffset = newOffset
}
}
private func requestTime(server: String, timeout: TimeInterval = 2.0) -> Date? {
let host = CFHostCreateWithName(nil, server as CFString).takeRetainedValue()
var resolved: DarwinBoolean = false
let started = CFHostStartInfoResolution(host, .addresses, nil)
guard started else { return nil }
guard
let unmanaged = CFHostGetAddressing(host, &resolved),
resolved.boolValue,
let addresses = unmanaged.takeUnretainedValue() as? [Data],
let first = addresses.first
else { return nil }
let socketFD = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
guard socketFD >= 0 else { return nil }
defer { close(socketFD) }
var tv = timeval(tv_sec: Int(timeout), tv_usec: 0)
setsockopt(socketFD, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addrStorage = sockaddr_storage()
first.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
memcpy(&addrStorage, base, min(raw.count, MemoryLayout<sockaddr_storage>.size))
}
guard addrStorage.ss_family == sa_family_t(AF_INET) else { return nil }
withUnsafeMutablePointer(to: &addrStorage) {
$0.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { p in
p.pointee.sin_port = in_port_t(123).bigEndian
}
}
var packet = Data(count: 48)
packet[0] = 0x1B
let sent = packet.withUnsafeBytes { ptr in
withUnsafePointer(to: &addrStorage) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
sendto(socketFD, ptr.baseAddress, ptr.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
}
guard sent == 48 else { return nil }
var recvBuf = Data(count: 48)
let received = recvBuf.withUnsafeMutableBytes { ptr in
recv(socketFD, ptr.baseAddress, ptr.count, 0)
}
guard received >= 48 else { return nil }
let seconds1900: UInt32 = recvBuf.withUnsafeBytes { ptr in
let b = ptr.bindMemory(to: UInt8.self)
return (UInt32(b[40]) << 24) | (UInt32(b[41]) << 16) | (UInt32(b[42]) << 8) | UInt32(b[43])
}
return Date(timeIntervalSince1970: TimeInterval(seconds1900) - 2_208_988_800)
}
}

View File

@@ -22,9 +22,7 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi
private var cachedList: [Clock_t] = [] private var cachedList: [Clock_t] = []
private var list: [Clock_t] { private var list: [Clock_t] {
get { get { self.cachedList }
return self.cachedList
}
set { set {
self.cachedList = newValue self.cachedList = newValue
@@ -47,9 +45,13 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi
private var footerView: NSStackView? = nil private var footerView: NSStackView? = nil
private var deleteButton: NSButton? = nil private var deleteButton: NSButton? = nil
private var ntpSync: Bool = false
public init(_ module: ModuleType) { public init(_ module: ModuleType) {
self.title = module.stringValue self.title = module.stringValue
self.ntpSync = Store.shared.bool(key: "\(self.title)_ntpSync", defaultValue: self.ntpSync)
super.init(frame: NSRect.zero) super.init(frame: NSRect.zero)
if let objects = Store.shared.data(key: "\(self.title)_list") { if let objects = Store.shared.data(key: "\(self.title)_list") {
@@ -109,6 +111,13 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi
let separator = NSBox() let separator = NSBox()
separator.boxType = .separator separator.boxType = .separator
self.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Sync with NTP server"), component: switchView(
action: #selector(self.toggleNTPSync),
state: self.ntpSync
))
]))
self.addArrangedSubview(self.scrollView) self.addArrangedSubview(self.scrollView)
self.addArrangedSubview(separator) self.addArrangedSubview(separator)
self.addArrangedSubview(self.footer()) self.addArrangedSubview(self.footer())
@@ -272,4 +281,9 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi
@objc private func openFormatHelp(_ sender: NSButton) { @objc private func openFormatHelp(_ sender: NSButton) {
NSWorkspace.shared.open(URL(string: "https://www.nsdateformatter.com")!) NSWorkspace.shared.open(URL(string: "https://www.nsdateformatter.com")!)
} }
@objc func toggleNTPSync(_ sender: NSControl) {
self.ntpSync = controlState(sender)
Store.shared.set(key: "\(self.title)_ntpSync", value: self.ntpSync)
self.callback()
}
} }

View File

@@ -63,6 +63,7 @@
5CA518382B543FE600EBCCC4 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA518372B543FE600EBCCC4 /* portal.swift */; }; 5CA518382B543FE600EBCCC4 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA518372B543FE600EBCCC4 /* portal.swift */; };
5CAA50722C8E417700B13E13 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAA50712C8E417700B13E13 /* Text.swift */; }; 5CAA50722C8E417700B13E13 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAA50712C8E417700B13E13 /* Text.swift */; };
5CB3878A2C35A7110030459D /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB387892C35A7110030459D /* widget.swift */; }; 5CB3878A2C35A7110030459D /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB387892C35A7110030459D /* widget.swift */; };
5CC3B4E52F5A033000775E2C /* reader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC3B4E42F5A032E00775E2C /* reader.swift */; };
5CCA5CD52D4E8DB3002917F0 /* libIOReport.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1E45552D11D66200525864 /* libIOReport.tbd */; }; 5CCA5CD52D4E8DB3002917F0 /* libIOReport.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1E45552D11D66200525864 /* libIOReport.tbd */; };
5CD342F42B2F2FB700225631 /* notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD342F32B2F2FB700225631 /* notifications.swift */; }; 5CD342F42B2F2FB700225631 /* notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD342F32B2F2FB700225631 /* notifications.swift */; };
5CE7E78C2C318512006BC92C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE7E78B2C318512006BC92C /* WidgetKit.framework */; }; 5CE7E78C2C318512006BC92C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE7E78B2C318512006BC92C /* WidgetKit.framework */; };
@@ -548,6 +549,7 @@
5CA518372B543FE600EBCCC4 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = "<group>"; }; 5CA518372B543FE600EBCCC4 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = "<group>"; };
5CAA50712C8E417700B13E13 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = "<group>"; }; 5CAA50712C8E417700B13E13 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = "<group>"; };
5CB387892C35A7110030459D /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = "<group>"; }; 5CB387892C35A7110030459D /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = "<group>"; };
5CC3B4E42F5A032E00775E2C /* reader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = reader.swift; sourceTree = "<group>"; };
5CD342F32B2F2FB700225631 /* notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = notifications.swift; sourceTree = "<group>"; }; 5CD342F32B2F2FB700225631 /* notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = notifications.swift; sourceTree = "<group>"; };
5CE7E78A2C318512006BC92C /* WidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5CE7E78A2C318512006BC92C /* WidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
5CE7E78B2C318512006BC92C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 5CE7E78B2C318512006BC92C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -867,6 +869,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C2229A829CCB41900F00E69 /* main.swift */, 5C2229A829CCB41900F00E69 /* main.swift */,
5CC3B4E42F5A032E00775E2C /* reader.swift */,
5C2229B729CE3F3300F00E69 /* popup.swift */, 5C2229B729CE3F3300F00E69 /* popup.swift */,
5C044F792B3DE6F3005F6951 /* portal.swift */, 5C044F792B3DE6F3005F6951 /* portal.swift */,
5C2229AE29CDC08700F00E69 /* settings.swift */, 5C2229AE29CDC08700F00E69 /* settings.swift */,
@@ -1717,7 +1720,7 @@
New, New,
); );
LastSwiftUpdateCheck = 1540; LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 2620; LastUpgradeCheck = 2630;
ORGANIZATIONNAME = "Serhiy Mytrovtsiy"; ORGANIZATIONNAME = "Serhiy Mytrovtsiy";
TargetAttributes = { TargetAttributes = {
5C22299C29CCB3C400F00E69 = { 5C22299C29CCB3C400F00E69 = {
@@ -2021,6 +2024,7 @@
files = ( files = (
5C2229AF29CDC08700F00E69 /* settings.swift in Sources */, 5C2229AF29CDC08700F00E69 /* settings.swift in Sources */,
5C044F7A2B3DE6F3005F6951 /* portal.swift in Sources */, 5C044F7A2B3DE6F3005F6951 /* portal.swift in Sources */,
5CC3B4E52F5A033000775E2C /* reader.swift in Sources */,
5C2229B829CE3F3300F00E69 /* popup.swift in Sources */, 5C2229B829CE3F3300F00E69 /* popup.swift in Sources */,
5C2229A929CCB41900F00E69 /* main.swift in Sources */, 5C2229A929CCB41900F00E69 /* main.swift in Sources */,
); );