From 510d9132a6df21315748389d2e9a5221064f58d0 Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Wed, 11 Mar 2026 18:06:52 +0100 Subject: [PATCH] feat: added an option to sync clock with ntp server and moved reader to separate file --- Modules/Clock/main.swift | 28 +++----- Modules/Clock/reader.swift | 123 ++++++++++++++++++++++++++++++++ Modules/Clock/settings.swift | 20 +++++- Stats.xcodeproj/project.pbxproj | 6 +- 4 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 Modules/Clock/reader.swift diff --git a/Modules/Clock/main.swift b/Modules/Clock/main.swift index 1e240bc4..24a73354 100644 --- a/Modules/Clock/main.swift +++ b/Modules/Clock/main.swift @@ -23,20 +23,12 @@ public struct Clock_t: Codable { public var value: Date? = nil var popupIndex: Int { - get { - Store.shared.int(key: "clock_\(self.id)_popupIndex", defaultValue: -1) - } - set { - Store.shared.set(key: "clock_\(self.id)_popupIndex", value: newValue) - } + get { Store.shared.int(key: "clock_\(self.id)_popupIndex", defaultValue: -1) } + set { Store.shared.set(key: "clock_\(self.id)_popupIndex", value: newValue) } } var popupState: Bool { - get { - Store.shared.bool(key: "clock_\(self.id)_popupState", defaultValue: true) - } - set { - Store.shared.set(key: "clock_\(self.id)_popupState", value: newValue) - } + get { Store.shared.bool(key: "clock_\(self.id)_popupState", defaultValue: true) } + set { Store.shared.set(key: "clock_\(self.id)_popupState", value: newValue) } } public func formatted() -> String { @@ -47,12 +39,6 @@ public struct Clock_t: Codable { } } -internal class ClockReader: Reader { - public override func read() { - self.callback(Date()) - } -} - public class Clock: Module { private let popupView: Popup = Popup(.clock) private let portalView: Portal @@ -85,6 +71,12 @@ public class Clock: Module { 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]) } diff --git a/Modules/Clock/reader.swift b/Modules/Clock/reader.swift new file mode 100644 index 00000000..b219c818 --- /dev/null +++ b/Modules/Clock/reader.swift @@ -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 { + 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.size)) + + var addrStorage = sockaddr_storage() + first.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + memcpy(&addrStorage, base, min(raw.count, MemoryLayout.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.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) + } +} diff --git a/Modules/Clock/settings.swift b/Modules/Clock/settings.swift index 47410c44..904598bf 100644 --- a/Modules/Clock/settings.swift +++ b/Modules/Clock/settings.swift @@ -22,9 +22,7 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi private var cachedList: [Clock_t] = [] private var list: [Clock_t] { - get { - return self.cachedList - } + get { self.cachedList } set { self.cachedList = newValue @@ -47,9 +45,13 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi private var footerView: NSStackView? = nil private var deleteButton: NSButton? = nil + private var ntpSync: Bool = false + public init(_ module: ModuleType) { self.title = module.stringValue + self.ntpSync = Store.shared.bool(key: "\(self.title)_ntpSync", defaultValue: self.ntpSync) + super.init(frame: NSRect.zero) 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() 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(separator) self.addArrangedSubview(self.footer()) @@ -272,4 +281,9 @@ internal class Settings: NSStackView, Settings_v, NSTableViewDelegate, NSTableVi @objc private func openFormatHelp(_ sender: NSButton) { 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() + } } diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index 5c1fde85..0e7cdfa1 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 5CA518382B543FE600EBCCC4 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA518372B543FE600EBCCC4 /* portal.swift */; }; 5CAA50722C8E417700B13E13 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAA50712C8E417700B13E13 /* Text.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 */; }; 5CD342F42B2F2FB700225631 /* notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD342F32B2F2FB700225631 /* notifications.swift */; }; 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 = ""; }; 5CAA50712C8E417700B13E13 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = ""; }; 5CB387892C35A7110030459D /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = ""; }; + 5CC3B4E42F5A032E00775E2C /* reader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = reader.swift; sourceTree = ""; }; 5CD342F32B2F2FB700225631 /* notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = notifications.swift; sourceTree = ""; }; 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; }; @@ -867,6 +869,7 @@ isa = PBXGroup; children = ( 5C2229A829CCB41900F00E69 /* main.swift */, + 5CC3B4E42F5A032E00775E2C /* reader.swift */, 5C2229B729CE3F3300F00E69 /* popup.swift */, 5C044F792B3DE6F3005F6951 /* portal.swift */, 5C2229AE29CDC08700F00E69 /* settings.swift */, @@ -1717,7 +1720,7 @@ New, ); LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 2630; ORGANIZATIONNAME = "Serhiy Mytrovtsiy"; TargetAttributes = { 5C22299C29CCB3C400F00E69 = { @@ -2021,6 +2024,7 @@ files = ( 5C2229AF29CDC08700F00E69 /* settings.swift in Sources */, 5C044F7A2B3DE6F3005F6951 /* portal.swift in Sources */, + 5CC3B4E52F5A033000775E2C /* reader.swift in Sources */, 5C2229B829CE3F3300F00E69 /* popup.swift in Sources */, 5C2229A929CCB41900F00E69 /* main.swift in Sources */, );