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 +21 -0
- oiiaw-0.1.0/PKG-INFO +68 -0
- oiiaw-0.1.0/README.md +36 -0
- oiiaw-0.1.0/oiiaw/__init__.py +1 -0
- oiiaw-0.1.0/oiiaw/__main__.py +125 -0
- oiiaw-0.1.0/oiiaw/assets/oiia-face.ico +0 -0
- oiiaw-0.1.0/oiiaw/assets/oiia-face.png +0 -0
- oiiaw-0.1.0/oiiaw/autostart.py +46 -0
- oiiaw-0.1.0/oiiaw/cloud_status.py +261 -0
- oiiaw-0.1.0/oiiaw/config.py +61 -0
- oiiaw-0.1.0/oiiaw/logger.py +63 -0
- oiiaw-0.1.0/oiiaw/paths.py +17 -0
- oiiaw-0.1.0/oiiaw/setup_wizard.py +125 -0
- oiiaw-0.1.0/oiiaw/status_file.py +68 -0
- oiiaw-0.1.0/oiiaw/sync_engine.py +437 -0
- oiiaw-0.1.0/oiiaw/tray.py +230 -0
- oiiaw-0.1.0/oiiaw/ui_assets.py +64 -0
- oiiaw-0.1.0/oiiaw.egg-info/PKG-INFO +68 -0
- oiiaw-0.1.0/oiiaw.egg-info/SOURCES.txt +29 -0
- oiiaw-0.1.0/oiiaw.egg-info/dependency_links.txt +1 -0
- oiiaw-0.1.0/oiiaw.egg-info/entry_points.txt +6 -0
- oiiaw-0.1.0/oiiaw.egg-info/requires.txt +8 -0
- oiiaw-0.1.0/oiiaw.egg-info/top_level.txt +1 -0
- oiiaw-0.1.0/pyproject.toml +54 -0
- oiiaw-0.1.0/setup.cfg +4 -0
- oiiaw-0.1.0/tests/test_cloud_status.py +134 -0
- oiiaw-0.1.0/tests/test_setup_wizard.py +26 -0
- oiiaw-0.1.0/tests/test_status_file.py +13 -0
- oiiaw-0.1.0/tests/test_sync_engine.py +329 -0
- oiiaw-0.1.0/tests/test_tray.py +54 -0
- oiiaw-0.1.0/tests/test_ui_assets.py +38 -0
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
|