oiiaw 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of oiiaw might be problematic. Click here for more details.

oiiaw-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zerossin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
oiiaw-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: oiiaw
3
+ Version: 0.1.0
4
+ Summary: Obsidian <-> iCloud sync bridge for Windows
5
+ Author: zerossin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/zerossin/oiiaw
8
+ Project-URL: Repository, https://github.com/zerossin/oiiaw
9
+ Project-URL: Issues, https://github.com/zerossin/oiiaw/issues
10
+ Keywords: obsidian,icloud,sync,windows
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Win32 (MS Windows)
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: watchdog>=4.0.0
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: colorama>=0.4.6
27
+ Requires-Dist: pystray>=0.19.5
28
+ Requires-Dist: pillow>=10.0.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # oiiaw
34
+
35
+ Obsidian ↔ iCloud, Windows용 자동 동기화 도구.
36
+
37
+ Obsidian을 iCloud Drive 폴더에서 직접 열면 저장할 때마다 충돌 파일이 생깁니다.
38
+ oiiaw는 로컬 폴더와 iCloud 폴더를 백그라운드에서 대신 동기화해서 이 문제를 없애줍니다.
39
+
40
+ ## 설치
41
+
42
+ 1. [Python 3.11+](https://www.python.org/downloads/) 설치 — **"Add python.exe to PATH"** 체크
43
+ 2. 코드 받기:
44
+ ```
45
+ git clone https://github.com/zerossin/oiiaw.git
46
+ ```
47
+ git이 없다면 이 페이지 위쪽 **Code → Download ZIP** → 압축 풀기
48
+ 3. 받은 폴더에서 `cmd` 열고:
49
+ ```
50
+ pip install .
51
+ ```
52
+ 4. `oiiaw-setup` 실행 → 로컬 폴더 / iCloud 폴더 선택 → 설치
53
+
54
+ ## 사용법
55
+
56
+ - Obsidian에서는 iCloud 폴더가 아닌 항상 **로컬 폴더**만 vault로 여세요.
57
+ - 트레이 아이콘 색: 🔵 대기 · 🟠 동기화 중 · 🔴 충돌/에러
58
+ - 트레이 클릭시 현재 상태와 최근 활동 목록이 뜹니다.
59
+ - 트레이 우클릭 → 재시작으로 안전하게 다시 시작할 수 있습니다.
60
+ - 트레이를 껐다면 `oiiaw-setup`을 다시 할 필요 없이 `oiiaw start`로
61
+ 기존 설정 그대로 다시 켤 수 있습니다.
62
+ - 터미널에서 `oiiaw status`로도 확인이 가능합니다.
63
+ - 자동 시작 등록이 실패하면 `oiiaw-setup`을 관리자 권한으로 다시 실행하세요.
64
+
65
+ ## 고급: 설정 파일 직접 편집
66
+
67
+ 마법사 대신 `config.example.yaml`을 `config.yaml`로 복사해 경로를 채우고
68
+ `oiiaw run -c config.yaml`로 실행할 수도 있습니다.
oiiaw-0.1.0/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # oiiaw
2
+
3
+ Obsidian ↔ iCloud, Windows용 자동 동기화 도구.
4
+
5
+ Obsidian을 iCloud Drive 폴더에서 직접 열면 저장할 때마다 충돌 파일이 생깁니다.
6
+ oiiaw는 로컬 폴더와 iCloud 폴더를 백그라운드에서 대신 동기화해서 이 문제를 없애줍니다.
7
+
8
+ ## 설치
9
+
10
+ 1. [Python 3.11+](https://www.python.org/downloads/) 설치 — **"Add python.exe to PATH"** 체크
11
+ 2. 코드 받기:
12
+ ```
13
+ git clone https://github.com/zerossin/oiiaw.git
14
+ ```
15
+ git이 없다면 이 페이지 위쪽 **Code → Download ZIP** → 압축 풀기
16
+ 3. 받은 폴더에서 `cmd` 열고:
17
+ ```
18
+ pip install .
19
+ ```
20
+ 4. `oiiaw-setup` 실행 → 로컬 폴더 / iCloud 폴더 선택 → 설치
21
+
22
+ ## 사용법
23
+
24
+ - Obsidian에서는 iCloud 폴더가 아닌 항상 **로컬 폴더**만 vault로 여세요.
25
+ - 트레이 아이콘 색: 🔵 대기 · 🟠 동기화 중 · 🔴 충돌/에러
26
+ - 트레이 클릭시 현재 상태와 최근 활동 목록이 뜹니다.
27
+ - 트레이 우클릭 → 재시작으로 안전하게 다시 시작할 수 있습니다.
28
+ - 트레이를 껐다면 `oiiaw-setup`을 다시 할 필요 없이 `oiiaw start`로
29
+ 기존 설정 그대로 다시 켤 수 있습니다.
30
+ - 터미널에서 `oiiaw status`로도 확인이 가능합니다.
31
+ - 자동 시작 등록이 실패하면 `oiiaw-setup`을 관리자 권한으로 다시 실행하세요.
32
+
33
+ ## 고급: 설정 파일 직접 편집
34
+
35
+ 마법사 대신 `config.example.yaml`을 `config.yaml`로 복사해 경로를 채우고
36
+ `oiiaw run -c config.yaml`로 실행할 수도 있습니다.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,125 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import time
5
+
6
+ from .config import Config
7
+ from .logger import Logger
8
+ from .sync_engine import SyncEngine
9
+ from .status_file import StatusReporter
10
+ from .tray import TrayApp
11
+ from .paths import default_config_path
12
+ from .ui_assets import apply_window_icon, configure_windows_app_identity
13
+
14
+
15
+ def _run(config_path):
16
+ configure_windows_app_identity()
17
+ config = Config.load(config_path)
18
+ logger = Logger(config.logs_dir, config.console_level, config.log_retention)
19
+
20
+ existing = StatusReporter.read(config.logs_dir)
21
+ if StatusReporter.is_fresh(existing, max_age=5.0):
22
+ message = f"oiiaw가 이미 실행 중입니다 (pid {existing['pid']}) — 중복 실행을 막기 위해 새로 시작하지 않습니다."
23
+ logger.error("START", message, level="important")
24
+ import tkinter as tk
25
+ from tkinter import messagebox
26
+ root = tk.Tk()
27
+ apply_window_icon(root)
28
+ root.withdraw()
29
+ messagebox.showerror("oiiaw", message)
30
+ root.destroy()
31
+ sys.exit(1)
32
+
33
+ for problem in config.validate():
34
+ if problem.blocking:
35
+ logger.error("CONFIG", problem.message, level="important")
36
+ sys.exit(1)
37
+ logger.warn("CONFIG", problem.message, level="important")
38
+
39
+ for path in (config.local_vault, config.cloud_vault, config.sync_baseline, config.logs_dir):
40
+ if path:
41
+ os.makedirs(path, exist_ok=True)
42
+
43
+ logger.info("START", f"local={config.local_vault} cloud={config.cloud_vault}", level="important")
44
+ engine = SyncEngine(config, logger)
45
+ TrayApp(config, logger, engine).run()
46
+
47
+
48
+ def cmd_run(args):
49
+ _run(args.config)
50
+
51
+
52
+ def cmd_setup(args):
53
+ from .setup_wizard import main as run_wizard
54
+ run_wizard()
55
+
56
+
57
+ def cmd_start(args):
58
+ """Launches the console-less tray (existing config, no re-mapping
59
+ folders) without blocking this terminal — unlike `run`, which stays
60
+ attached. If oiiaw is already running, the launched process's own
61
+ duplicate-instance check handles that."""
62
+ from . import autostart
63
+ if not autostart.start_now():
64
+ print("oiiaw-tray.exe를 찾을 수 없습니다. pip install이 정상적으로 끝났는지 확인하세요.")
65
+ sys.exit(1)
66
+ print("oiiaw를 시작했습니다.")
67
+
68
+
69
+ def main_tray():
70
+ """Entry point for the console-less `oiiaw-tray` GUI script — what
71
+ Task Scheduler launches at logon, and what the setup wizard's
72
+ "start now" offer runs. It has no console to report to, so if setup
73
+ was never completed (no config.yaml yet), fall into the setup wizard
74
+ instead of crashing silently."""
75
+ config_path = default_config_path()
76
+ if not os.path.isfile(config_path):
77
+ from .setup_wizard import main as run_wizard
78
+ run_wizard()
79
+ return
80
+ _run(config_path)
81
+
82
+
83
+ def cmd_status(args):
84
+ config = Config.load(args.config)
85
+ problems = config.validate()
86
+ if not problems:
87
+ print("config: ok")
88
+ for problem in problems:
89
+ tag = "blocking" if problem.blocking else "warning"
90
+ print(f"config [{tag}]: {problem.message}")
91
+
92
+ status = StatusReporter.read(config.logs_dir)
93
+ if StatusReporter.is_fresh(status):
94
+ uptime = int(time.time() - status["started_at"])
95
+ print(
96
+ f"daemon: running (pid {status['pid']}, uptime {uptime}s, "
97
+ f"state={status['state']}, pending={status['pending']}, parked={status.get('parked', 0)})"
98
+ )
99
+ last = status.get("last_event")
100
+ if last:
101
+ print(f" last event: {last['type']} {last['path']} ({int(time.time() - last['time'])}s ago)")
102
+ print(f" this session: {status['conflict_count']} conflicts, {status['error_count']} errors")
103
+ else:
104
+ print("daemon: not running (or not responding)")
105
+
106
+ for name, path in (("local_vault", config.local_vault), ("cloud_vault", config.cloud_vault), ("sync_baseline", config.sync_baseline)):
107
+ count = sum(len(files) for _, _, files in os.walk(path)) if path and os.path.isdir(path) else 0
108
+ print(f"{name}: {count} files ({path or 'not set'})")
109
+
110
+
111
+ def main():
112
+ parser = argparse.ArgumentParser(prog="oiiaw", description="Obsidian <-> iCloud sync bridge for Windows")
113
+ parser.add_argument("-c", "--config", default=default_config_path(), help="path to config YAML (default: %(default)s)")
114
+ sub = parser.add_subparsers(dest="command", required=True)
115
+ sub.add_parser("run", help="start the sync daemon (blocks this terminal)").set_defaults(func=cmd_run)
116
+ sub.add_parser("start", help="start the tray in the background, using the existing config").set_defaults(func=cmd_start)
117
+ sub.add_parser("status", help="check config and vault file counts").set_defaults(func=cmd_status)
118
+ sub.add_parser("setup", help="run the setup wizard (pick folders, register autostart)").set_defaults(func=cmd_setup)
119
+
120
+ args = parser.parse_args()
121
+ args.func(args)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
Binary file
Binary file
@@ -0,0 +1,46 @@
1
+ """
2
+ Registers oiiaw to start at Windows logon via Task Scheduler. Targets the
3
+ `oiiaw-tray` GUI entry point (Windows-subsystem exe, no console window) —
4
+ not the `oiiaw` console script — since this runs unattended on every login.
5
+ """
6
+
7
+ import sys
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ TASK_NAME = "oiiaw"
12
+
13
+
14
+ def _locate_tray_exe() -> str | None:
15
+ python_dir = Path(sys.executable).parent
16
+ for candidate in (python_dir / "oiiaw-tray.exe", python_dir / "Scripts" / "oiiaw-tray.exe"):
17
+ if candidate.is_file():
18
+ return str(candidate)
19
+ return None
20
+
21
+
22
+ def register() -> tuple[bool, str]:
23
+ exe = _locate_tray_exe()
24
+ if not exe:
25
+ return False, "oiiaw-tray.exe를 찾을 수 없습니다 — pip install이 정상적으로 끝났는지 확인해주세요."
26
+ result = subprocess.run(
27
+ ["schtasks", "/create", "/tn", TASK_NAME, "/tr", f'"{exe}"', "/sc", "onlogon", "/rl", "limited", "/f"],
28
+ capture_output=True, text=True,
29
+ )
30
+ if result.returncode != 0:
31
+ reason = (result.stderr or result.stdout or "알 수 없는 오류").strip()
32
+ return False, (
33
+ f"자동 시작 등록에 실패했습니다: {reason}\n"
34
+ "일부 PC에서는 관리자 권한이 필요할 수 있어요 — 마법사를 관리자 권한으로 "
35
+ "다시 실행해보세요. (동기화 자체는 계속 쓸 수 있고, 나중에 `oiiaw run`으로 "
36
+ "직접 실행하면 됩니다.)"
37
+ )
38
+ return True, "Windows 시작 시 자동으로 실행되도록 등록했습니다."
39
+
40
+
41
+ def start_now() -> bool:
42
+ exe = _locate_tray_exe()
43
+ if not exe:
44
+ return False
45
+ subprocess.Popen([exe])
46
+ return True
@@ -0,0 +1,261 @@
1
+ """
2
+ Ground-truth check for whether a Windows cloud-sync placeholder (iCloud,
3
+ OneDrive, etc.) actually has its bytes on disk right now, via the Cloud
4
+ Filter API (CldApi.dll / CfGetPlaceholderInfo) — not by inferring from
5
+ GetFileAttributesW bits.
6
+
7
+ Struct layout and enum values below are taken verbatim from Microsoft Learn
8
+ (cfapi.h): CF_PLACEHOLDER_STANDARD_INFO, CF_PLACEHOLDER_INFO_CLASS,
9
+ CF_PIN_STATE. Verified against real files in the user's iCloud vault, not
10
+ just written from memory — see tests/test_cloud_status.py.
11
+ """
12
+
13
+ import ctypes
14
+ import asyncio
15
+ import multiprocessing
16
+ import os
17
+ import platform
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+
21
+ FILE_READ_ATTRIBUTES = 0x0080
22
+ FILE_SHARE_READ_WRITE_DELETE = 0x1 | 0x2 | 0x4
23
+ OPEN_EXISTING = 3
24
+ FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
25
+ FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
26
+ INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
27
+
28
+ CF_PLACEHOLDER_INFO_STANDARD = 1
29
+
30
+
31
+ class PinState(Enum):
32
+ UNSPECIFIED = 0
33
+ PINNED = 1
34
+ UNPINNED = 2
35
+ EXCLUDED = 3
36
+ INHERIT = 4
37
+
38
+
39
+ class _CF_PLACEHOLDER_STANDARD_INFO(ctypes.Structure):
40
+ _fields_ = [
41
+ ("OnDiskDataSize", ctypes.c_int64),
42
+ ("ValidatedDataSize", ctypes.c_int64),
43
+ ("ModifiedDataSize", ctypes.c_int64),
44
+ ("PropertiesSize", ctypes.c_int64),
45
+ ("PinState", ctypes.c_int32),
46
+ ("InSyncState", ctypes.c_int32),
47
+ ("FileId", ctypes.c_int64),
48
+ ("SyncRootFileId", ctypes.c_int64),
49
+ ("FileIdentityLength", ctypes.c_uint32),
50
+ ]
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PlaceholderInfo:
55
+ on_disk_bytes: int
56
+ validated_bytes: int
57
+ pin_state: PinState
58
+ in_sync: bool
59
+
60
+
61
+ class CloudFilterUnavailable(Exception):
62
+ """Raised when the Cloud Filter API isn't usable on this system at all."""
63
+
64
+
65
+ class CloudProbeTimeout(TimeoutError):
66
+ """The isolated Windows cloud-status probe stopped responding."""
67
+
68
+
69
+ class CloudProbeError(RuntimeError):
70
+ """The isolated probe exited or returned an unexpected failure."""
71
+
72
+
73
+ def _load_cldapi():
74
+ if platform.system() != "Windows":
75
+ raise CloudFilterUnavailable("Cloud Filter API is Windows-only")
76
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
77
+ k32.CreateFileW.argtypes = [
78
+ ctypes.c_wchar_p,
79
+ ctypes.c_uint32,
80
+ ctypes.c_uint32,
81
+ ctypes.c_void_p,
82
+ ctypes.c_uint32,
83
+ ctypes.c_uint32,
84
+ ctypes.c_void_p,
85
+ ]
86
+ k32.CreateFileW.restype = ctypes.c_void_p
87
+ k32.CloseHandle.argtypes = [ctypes.c_void_p]
88
+
89
+ cldapi = ctypes.WinDLL("cldapi", use_last_error=True)
90
+ cldapi.CfGetPlaceholderInfo.argtypes = [
91
+ ctypes.c_void_p,
92
+ ctypes.c_int,
93
+ ctypes.c_void_p,
94
+ ctypes.c_uint32,
95
+ ctypes.POINTER(ctypes.c_uint32),
96
+ ]
97
+ cldapi.CfGetPlaceholderInfo.restype = ctypes.c_long
98
+ return k32, cldapi
99
+
100
+
101
+ class CloudFilter:
102
+ def __init__(self):
103
+ self._available = True
104
+ try:
105
+ self._k32, self._cldapi = _load_cldapi()
106
+ except (CloudFilterUnavailable, AttributeError, OSError):
107
+ self._available = False
108
+
109
+ def get_placeholder_info(self, path: str) -> PlaceholderInfo | None:
110
+ """
111
+ Returns None if `path` isn't tracked as a cloud-filter placeholder at
112
+ all (plain local file, or the API call otherwise failed) — callers
113
+ should treat that as "content is available".
114
+ """
115
+ if not self._available:
116
+ return None
117
+
118
+ handle = self._k32.CreateFileW(
119
+ str(path),
120
+ FILE_READ_ATTRIBUTES,
121
+ FILE_SHARE_READ_WRITE_DELETE,
122
+ None,
123
+ OPEN_EXISTING,
124
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
125
+ None,
126
+ )
127
+ if handle is None or handle == INVALID_HANDLE_VALUE:
128
+ return None
129
+
130
+ try:
131
+ # CF_PLACEHOLDER_STANDARD_INFO ends with a variable-length
132
+ # FileIdentity blob — sizeof() on the fixed struct alone is too
133
+ # small and the call fails with ERROR_MORE_DATA. Over-allocate
134
+ # and reinterpret just the fixed prefix we care about.
135
+ buf = ctypes.create_string_buffer(4096)
136
+ returned = ctypes.c_uint32(0)
137
+ hr = self._cldapi.CfGetPlaceholderInfo(
138
+ handle,
139
+ CF_PLACEHOLDER_INFO_STANDARD,
140
+ buf,
141
+ ctypes.sizeof(buf),
142
+ ctypes.byref(returned),
143
+ )
144
+ if hr != 0:
145
+ return None # not a placeholder, or the call failed — caller falls back to "available"
146
+ info = ctypes.cast(buf, ctypes.POINTER(_CF_PLACEHOLDER_STANDARD_INFO)).contents
147
+ return PlaceholderInfo(
148
+ on_disk_bytes=info.OnDiskDataSize,
149
+ validated_bytes=info.ValidatedDataSize,
150
+ pin_state=PinState(info.PinState),
151
+ in_sync=bool(info.InSyncState),
152
+ )
153
+ finally:
154
+ self._k32.CloseHandle(handle)
155
+
156
+ def is_content_available(self, path: str) -> bool:
157
+ """True if every byte of the file is already on disk — safe to read
158
+ or copy right now without triggering a network fetch."""
159
+ info = self.get_placeholder_info(path)
160
+ if info is None:
161
+ return True
162
+ try:
163
+ full_size = os.path.getsize(path)
164
+ except OSError:
165
+ return False
166
+ return info.on_disk_bytes >= full_size
167
+
168
+
169
+ def _probe_worker(connection):
170
+ """Runs unsafe provider calls outside the sync process's event loop.
171
+
172
+ A cloud provider can block inside CreateFileW/CfGetPlaceholderInfo without
173
+ raising. The parent can terminate this process; it cannot safely terminate
174
+ a stuck Python thread.
175
+ """
176
+ cloud_filter = CloudFilter()
177
+ try:
178
+ while True:
179
+ try:
180
+ path = connection.recv()
181
+ except EOFError:
182
+ return
183
+ if path is None:
184
+ return
185
+ try:
186
+ connection.send(("ok", cloud_filter.is_content_available(path)))
187
+ except BaseException as exc:
188
+ connection.send(("error", f"{type(exc).__name__}: {exc}"))
189
+ finally:
190
+ connection.close()
191
+
192
+
193
+ class CloudProbe:
194
+ """Async facade over one disposable cloud-status helper process."""
195
+
196
+ def __init__(self, timeout_seconds: float = 5.0, context=None):
197
+ self.timeout_seconds = timeout_seconds
198
+ self._context = context or multiprocessing.get_context("spawn")
199
+ self._connection = None
200
+ self._process = None
201
+ self._lock: asyncio.Lock | None = None
202
+
203
+ def _ensure_worker(self):
204
+ if self._process is not None and self._process.is_alive():
205
+ return
206
+ self._discard_worker()
207
+ parent, child = self._context.Pipe()
208
+ process = self._context.Process(
209
+ target=_probe_worker,
210
+ args=(child,),
211
+ name="oiiaw-cloud-probe",
212
+ daemon=True,
213
+ )
214
+ process.start()
215
+ child.close()
216
+ self._connection = parent
217
+ self._process = process
218
+
219
+ def _discard_worker(self):
220
+ connection, process = self._connection, self._process
221
+ self._connection = None
222
+ self._process = None
223
+ if connection is not None:
224
+ connection.close()
225
+ if process is not None:
226
+ if process.is_alive():
227
+ process.terminate()
228
+ process.join(timeout=0.2)
229
+ if process.is_alive():
230
+ process.kill()
231
+ process.join(timeout=0.2)
232
+
233
+ async def is_content_available(self, path: str) -> bool:
234
+ if self._lock is None:
235
+ self._lock = asyncio.Lock()
236
+ async with self._lock:
237
+ self._ensure_worker()
238
+ try:
239
+ self._connection.send(str(path))
240
+ except (BrokenPipeError, EOFError, OSError) as exc:
241
+ self._discard_worker()
242
+ raise CloudProbeError(f"could not contact cloud probe: {exc}") from exc
243
+
244
+ # Pipe polling has its own hard timeout and wakes immediately on a
245
+ # response. Running only that bounded wait in a thread avoids both
246
+ # event-loop blocking and a 50 ms polling tax for every vault file.
247
+ ready = await asyncio.to_thread(self._connection.poll, self.timeout_seconds)
248
+ if not ready:
249
+ self._discard_worker()
250
+ raise CloudProbeTimeout(f"cloud probe exceeded {self.timeout_seconds:.1f}s")
251
+ try:
252
+ kind, payload = self._connection.recv()
253
+ except (EOFError, OSError) as exc:
254
+ self._discard_worker()
255
+ raise CloudProbeError(f"cloud probe exited: {exc}") from exc
256
+ if kind == "ok":
257
+ return bool(payload)
258
+ raise CloudProbeError(str(payload))
259
+
260
+ def close(self):
261
+ self._discard_worker()
@@ -0,0 +1,61 @@
1
+ import os
2
+ import glob
3
+ import yaml
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def discover_icloud_vault() -> str | None:
8
+ """Finds the Obsidian iCloud container under the user's iCloudDrive folder."""
9
+ home = os.path.expanduser("~")
10
+ candidates = glob.glob(os.path.join(home, "iCloudDrive", "iCloud~md~obsidian"))
11
+ return candidates[0] if candidates else None
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ConfigProblem:
16
+ message: str
17
+ blocking: bool # True: can't start; False: worth a warning but startable
18
+
19
+
20
+ class Config:
21
+ def __init__(self, data: dict):
22
+ paths = data.get("paths", {})
23
+ self.local_vault = paths.get("local_vault", "")
24
+ self.cloud_vault = paths.get("cloud_vault") or discover_icloud_vault() or ""
25
+ self.sync_baseline = paths.get("sync_baseline", "")
26
+ self.logs_dir = paths.get("logs_dir", "")
27
+
28
+ sync = data.get("sync", {})
29
+ self.stability_window = sync.get("stability_window", 3)
30
+ self.stabilize_wait = sync.get("stabilize_wait", 8)
31
+ self.cooldown_seconds = sync.get("cooldown_seconds", 3)
32
+ self.cloud_probe_timeout = sync.get("cloud_probe_timeout", 5)
33
+ self.big_file_threshold = sync.get("big_file_threshold", 102400)
34
+ self.big_file_cooldown = sync.get("big_file_cooldown", 30)
35
+ logging_cfg = data.get("logging", {})
36
+ self.console_level = logging_cfg.get("console_level", "normal")
37
+ self.log_retention = logging_cfg.get("log_retention", 10)
38
+
39
+ ignore = data.get("ignore", {})
40
+ self.ignored_dirs = {d.lower() for d in ignore.get("dirs", [])}
41
+ self.ignored_files = {f.lower() for f in ignore.get("files", [])}
42
+ self.ignore_patterns = ignore.get("patterns", [])
43
+
44
+ @classmethod
45
+ def load(cls, path: str) -> "Config":
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ data = yaml.safe_load(f) or {}
48
+ return cls(data)
49
+
50
+ def validate(self) -> list[ConfigProblem]:
51
+ problems = []
52
+ if not self.local_vault:
53
+ problems.append(ConfigProblem("paths.local_vault is not set", blocking=True))
54
+ if not self.cloud_vault:
55
+ problems.append(ConfigProblem("paths.cloud_vault is not set and could not be auto-discovered", blocking=True))
56
+ if self.local_vault and self.cloud_vault and os.path.normcase(self.local_vault) == os.path.normcase(self.cloud_vault):
57
+ problems.append(ConfigProblem("local_vault and cloud_vault must not be the same folder", blocking=True))
58
+ for name, value in (("local_vault", self.local_vault), ("cloud_vault", self.cloud_vault)):
59
+ if value and not os.path.isdir(value):
60
+ problems.append(ConfigProblem(f"{name} does not exist yet: {value}", blocking=False))
61
+ return problems