77import proto
88
99from actual .crypto import decrypt , encrypt
10- from actual .exceptions import ActualDecryptionError
10+ from actual .exceptions import ActualDecryptionError , ActualOverflowError
1111
1212"""
1313Protobuf message definitions taken from the [sync.proto file](
@@ -26,10 +26,12 @@ class HULC_Client:
2626 clocks.
2727 """
2828
29+ MAX_COUNTER : int = 0xFFFF
30+
2931 def __init__ (self , client_id : str | None = None , initial_count : int = 0 , ts : datetime .datetime | None = None ):
3032 self .client_id = client_id or self .random_client_id ()
3133 self .initial_count = initial_count
32- self .ts = ts or datetime . datetime ( 1970 , 1 , 1 , 0 , 0 , 0 )
34+ self .ts = ts
3335
3436 @classmethod
3537 def from_timestamp (cls , ts : str ) -> HULC_Client :
@@ -40,8 +42,8 @@ def from_timestamp(cls, ts: str) -> HULC_Client:
4042 return cls (segments [- 1 ], int (segments [- 2 ], 16 ), parsed_ts )
4143
4244 def __str__ (self ):
43- count = f" { self .initial_count :0>4X } "
44- return f"{ self . ts .isoformat (timespec = 'milliseconds' )} Z-{ count } -{ self .client_id } "
45+ ts = self .ts or datetime . datetime ( 1970 , 1 , 1 , 0 , 0 , 0 )
46+ return f"{ ts .isoformat (timespec = 'milliseconds' )} Z-{ self . initial_count :0>4X } -{ self .client_id } "
4547
4648 def timestamp (self , now : datetime .datetime | None = None ) -> str :
4749 """Actual uses Hybrid Unique Logical Clock (HULC) timestamp generator.
@@ -55,11 +57,23 @@ def timestamp(self, now: datetime.datetime | None = None) -> str:
5557 https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts)
5658 for reference.
5759 """
58- if not now :
59- now = datetime .datetime .now (datetime .timezone .utc ).replace (tzinfo = None )
60- count = f"{ self .initial_count :0>4X} "
61- self .initial_count += 1
62- return f"{ now .isoformat (timespec = 'milliseconds' )} Z-{ count } -{ self .client_id } "
60+ current_time = datetime .datetime .now (datetime .timezone .utc ).replace (tzinfo = None ) if now is None else now
61+ # truncate to millisecond precision to match the Node.js Date.now() behavior
62+ current_time = current_time .replace (microsecond = current_time .microsecond // 1000 * 1000 )
63+ # ensure that the logical time never goes backward
64+ new_logical_time = current_time if self .ts is None else max (self .ts , current_time )
65+ # advance the counter if same millisecond, otherwise reset to 0
66+ new_counter = self .initial_count + 1 if (self .ts is not None and self .ts == new_logical_time ) else 0
67+
68+ if new_counter > self .MAX_COUNTER :
69+ raise ActualOverflowError (
70+ f"Timestamp counter overflow (>{ self .MAX_COUNTER } ). "
71+ "Too many sync messages were generated without the clock advancing."
72+ )
73+
74+ self .ts = new_logical_time
75+ self .initial_count = new_counter
76+ return str (self )
6377
6478 @staticmethod
6579 def random_client_id ():
@@ -135,6 +149,16 @@ def set_timestamp(
135149 self .timestamp = HULC_Client (client_id , initial_count ).timestamp (now )
136150 return self .timestamp
137151
152+ def message (self , master_key : bytes | None = None ) -> Message :
153+ if self .isEncrypted :
154+ if not master_key :
155+ raise ActualDecryptionError ("Master key not provided and data is encrypted." )
156+ encrypted = EncryptedData .deserialize (self .content )
157+ content = decrypt (master_key , encrypted .iv , encrypted .data , encrypted .authTag )
158+ else :
159+ content = self .content
160+ return Message .deserialize (content )
161+
138162
139163class SyncRequest (proto .Message ):
140164 """Sync request message that is sent to the server for retrieving new messages since the last synchronization."""
@@ -185,12 +209,5 @@ class SyncResponse(proto.Message):
185209 def get_messages (self , master_key : bytes | None = None ) -> list [Message ]:
186210 messages = []
187211 for message in self .messages : # noqa
188- if message .isEncrypted :
189- if not master_key :
190- raise ActualDecryptionError ("Master key not provided and data is encrypted." )
191- encrypted = EncryptedData .deserialize (message .content )
192- content = decrypt (master_key , encrypted .iv , encrypted .data , encrypted .authTag )
193- else :
194- content = message .content
195- messages .append (Message .deserialize (content ))
212+ messages .append (message .message (master_key ))
196213 return messages
0 commit comments