mirror of
https://github.com/morgan9e/macos-stats
synced 2026-04-13 15:54:10 +09:00
feat: added an option to sync clock with ntp server and moved reader to separate file
This commit is contained in:
@@ -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<Date> {
|
||||
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])
|
||||
}
|
||||
|
||||
|
||||
123
Modules/Clock/reader.swift
Normal file
123
Modules/Clock/reader.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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 */,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user