safekeep 0.2.0__tar.gz → 0.3.1__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.
Files changed (26) hide show
  1. {safekeep-0.2.0 → safekeep-0.3.1}/PKG-INFO +51 -23
  2. safekeep-0.2.0/safekeep.egg-info/PKG-INFO → safekeep-0.3.1/README.md +40 -37
  3. {safekeep-0.2.0 → safekeep-0.3.1}/pyproject.toml +10 -4
  4. safekeep-0.3.1/safekeep/__init__.py +6 -0
  5. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/cli.py +107 -31
  6. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/daemon.py +10 -7
  7. safekeep-0.3.1/safekeep/platform.py +119 -0
  8. safekeep-0.2.0/README.md → safekeep-0.3.1/safekeep.egg-info/PKG-INFO +65 -18
  9. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/SOURCES.txt +2 -0
  10. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_cli.py +148 -2
  11. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_daemon.py +18 -3
  12. safekeep-0.3.1/tests/test_platform.py +231 -0
  13. safekeep-0.2.0/safekeep/__init__.py +0 -6
  14. {safekeep-0.2.0 → safekeep-0.3.1}/LICENSE +0 -0
  15. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/config.py +0 -0
  16. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/copier.py +0 -0
  17. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/matcher.py +0 -0
  18. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/volumes.py +0 -0
  19. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/dependency_links.txt +0 -0
  20. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/entry_points.txt +0 -0
  21. {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/top_level.txt +0 -0
  22. {safekeep-0.2.0 → safekeep-0.3.1}/setup.cfg +0 -0
  23. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_config.py +0 -0
  24. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_copier.py +0 -0
  25. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_matcher.py +0 -0
  26. {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_volumes.py +0 -0
@@ -1,16 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safekeep
3
- Version: 0.2.0
4
- Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
3
+ Version: 0.3.1
4
+ Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
5
5
  License-Expression: MIT
6
- Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
7
- Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
8
- Keywords: backup,macos,fswatch,sync,allow-list,launchd
6
+ Project-URL: Repository, https://github.com/foxhound87/safekeep
7
+ Project-URL: Homepage, https://foxhound87.github.io/safekeep/
8
+ Project-URL: Documentation, https://foxhound87.github.io/safekeep/
9
+ Keywords: backup,macos,linux,fswatch,sync,allow-list,launchd,systemd
9
10
  Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Environment :: Console
11
12
  Classifier: Intended Audience :: System Administrators
12
13
  Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Operating System :: POSIX :: Linux
13
16
  Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.14
14
20
  Classifier: Topic :: System :: Archiving :: Backup
15
21
  Requires-Python: >=3.9
16
22
  Description-Content-Type: text/markdown
@@ -19,12 +25,17 @@ Dynamic: license-file
19
25
 
20
26
  # safekeep
21
27
 
22
- **Selective always-on backups for macOS**: watches source folders with
28
+ [![PyPI](https://img.shields.io/pypi/v/safekeep)](https://pypi.org/project/safekeep/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/safekeep)](https://pypi.org/project/safekeep/)
30
+
31
+ **Selective always-on backups for macOS and Linux**: watches source folders with
23
32
  [fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
24
33
  chose to their destinations — an **allow-list** model that **never deletes**
25
34
  anything in the backup (a file removed or renamed at the source stays in the
26
35
  backup).
27
36
 
37
+ Documentation: https://foxhound87.github.io/safekeep/
38
+
28
39
  ## Features
29
40
 
30
41
  - **Allow-list**: a file is copied only if it matches at least one `include:`;
@@ -38,7 +49,8 @@ backup).
38
49
  don't matter, the next reconcile brings everything back in sync.
39
50
  - **Unmounted volumes**: missing destination → `pending` state with exponential
40
51
  backoff (1s → 60s cap), then a reconcile once the mount is back.
41
- - **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
52
+ - **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
53
+ `KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
42
54
  alive and restarts it if it dies.
43
55
  - **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
44
56
  `.sync` file can neither add nor remove destinations (a `dest:` line is an
@@ -53,27 +65,31 @@ pipx install safekeep # recommended for a CLI
53
65
  pip install safekeep
54
66
  ```
55
67
 
56
- **Requirements**: macOS, Python >= 3.9 and
68
+ **Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
57
69
  [fswatch](https://github.com/emcrisostomo/fswatch):
58
70
 
59
71
  ```bash
60
- brew install fswatch
72
+ brew install fswatch # macOS
73
+ sudo pacman -S fswatch # Arch / Omarchy
74
+ sudo apt install fswatch # Debian / Ubuntu
75
+ sudo dnf install fswatch # Fedora
61
76
  ```
62
77
 
63
- The **launchd agent** (daemon at boot) is installed from a checkout of this
64
- repository with `./install.sh` — not from the wheel — see
65
- [Agent (launchd)](#agent-launchd) below.
78
+ The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
79
+ is installed from a checkout of this repository with `./install.sh` — not from
80
+ the wheel — see [Agent](#agent) below.
66
81
 
67
- ## Agent (launchd)
82
+ ## Agent
68
83
 
69
84
  ```bash
70
- git clone https://gitlab.com/foxhound87/safekeep.git
85
+ git clone https://github.com/foxhound87/safekeep.git
71
86
  cd safekeep
72
87
 
73
- # only external dependency, if not installed yet
74
- brew install fswatch
88
+ # only external dependency, if not installed yet (see the per-OS list above)
89
+ brew install fswatch # macOS — the script suggests the right command on Linux
75
90
 
76
- # copies the example into ~/.safekeep, renders the plist, runs preflight checks
91
+ # copies the example into ~/.safekeep, renders the plist / systemd unit,
92
+ # runs preflight checks
77
93
  bash install.sh
78
94
  ```
79
95
 
@@ -88,12 +104,21 @@ Then:
88
104
  3. load the agent:
89
105
 
90
106
  ```bash
107
+ # macOS (launchd)
91
108
  launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
92
109
  # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
110
+
111
+ # Linux (systemd user unit)
112
+ systemctl --user enable --now safekeep
113
+ # status with: systemctl --user status safekeep
114
+ # logs with: journalctl --user -u safekeep -f
115
+ # unload with: systemctl --user disable --now safekeep
116
+ # start at boot without a login session: loginctl enable-linger $USER
93
117
  ```
94
118
 
95
- Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
96
- interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
119
+ On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
120
+ Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
121
+ Linux the same command checks the `inotify` watch limit instead.
97
122
 
98
123
  ## Quickstart
99
124
 
@@ -157,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
157
182
  | `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
158
183
  | `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
159
184
  | `status` | read-only: config, sources, discovered projects with N rules, destination states |
160
- | `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
185
+ | `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
161
186
 
162
187
  ## Tests
163
188
 
@@ -165,9 +190,12 @@ safekeep <command> [--config PATH] [-v]
165
190
  python3 -m unittest discover -s tests
166
191
  ```
167
192
 
168
- Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
169
- config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
- needed to run the tests.
193
+ Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
194
+ config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
195
+ portability layer (platform helper, per-OS fswatch monitor, systemd template).
196
+ `fswatch` is not needed to run the suite — the few `doctor` checks that talk to
197
+ the real binary are skipped when it's missing — but CI installs it so the
198
+ Linux job exercises the real `inotify` monitor.
171
199
 
172
200
  ## Documentation
173
201
 
@@ -1,30 +1,16 @@
1
- Metadata-Version: 2.4
2
- Name: safekeep
3
- Version: 0.2.0
4
- Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
5
- License-Expression: MIT
6
- Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
7
- Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
8
- Keywords: backup,macos,fswatch,sync,allow-list,launchd
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: System Administrators
12
- Classifier: Operating System :: MacOS
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Topic :: System :: Archiving :: Backup
15
- Requires-Python: >=3.9
16
- Description-Content-Type: text/markdown
17
- License-File: LICENSE
18
- Dynamic: license-file
19
-
20
1
  # safekeep
21
2
 
22
- **Selective always-on backups for macOS**: watches source folders with
3
+ [![PyPI](https://img.shields.io/pypi/v/safekeep)](https://pypi.org/project/safekeep/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/safekeep)](https://pypi.org/project/safekeep/)
5
+
6
+ **Selective always-on backups for macOS and Linux**: watches source folders with
23
7
  [fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
24
8
  chose to their destinations — an **allow-list** model that **never deletes**
25
9
  anything in the backup (a file removed or renamed at the source stays in the
26
10
  backup).
27
11
 
12
+ Documentation: https://foxhound87.github.io/safekeep/
13
+
28
14
  ## Features
29
15
 
30
16
  - **Allow-list**: a file is copied only if it matches at least one `include:`;
@@ -38,7 +24,8 @@ backup).
38
24
  don't matter, the next reconcile brings everything back in sync.
39
25
  - **Unmounted volumes**: missing destination → `pending` state with exponential
40
26
  backoff (1s → 60s cap), then a reconcile once the mount is back.
41
- - **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
27
+ - **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
28
+ `KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
42
29
  alive and restarts it if it dies.
43
30
  - **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
44
31
  `.sync` file can neither add nor remove destinations (a `dest:` line is an
@@ -53,27 +40,31 @@ pipx install safekeep # recommended for a CLI
53
40
  pip install safekeep
54
41
  ```
55
42
 
56
- **Requirements**: macOS, Python >= 3.9 and
43
+ **Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
57
44
  [fswatch](https://github.com/emcrisostomo/fswatch):
58
45
 
59
46
  ```bash
60
- brew install fswatch
47
+ brew install fswatch # macOS
48
+ sudo pacman -S fswatch # Arch / Omarchy
49
+ sudo apt install fswatch # Debian / Ubuntu
50
+ sudo dnf install fswatch # Fedora
61
51
  ```
62
52
 
63
- The **launchd agent** (daemon at boot) is installed from a checkout of this
64
- repository with `./install.sh` — not from the wheel — see
65
- [Agent (launchd)](#agent-launchd) below.
53
+ The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
54
+ is installed from a checkout of this repository with `./install.sh` — not from
55
+ the wheel — see [Agent](#agent) below.
66
56
 
67
- ## Agent (launchd)
57
+ ## Agent
68
58
 
69
59
  ```bash
70
- git clone https://gitlab.com/foxhound87/safekeep.git
60
+ git clone https://github.com/foxhound87/safekeep.git
71
61
  cd safekeep
72
62
 
73
- # only external dependency, if not installed yet
74
- brew install fswatch
63
+ # only external dependency, if not installed yet (see the per-OS list above)
64
+ brew install fswatch # macOS — the script suggests the right command on Linux
75
65
 
76
- # copies the example into ~/.safekeep, renders the plist, runs preflight checks
66
+ # copies the example into ~/.safekeep, renders the plist / systemd unit,
67
+ # runs preflight checks
77
68
  bash install.sh
78
69
  ```
79
70
 
@@ -88,12 +79,21 @@ Then:
88
79
  3. load the agent:
89
80
 
90
81
  ```bash
82
+ # macOS (launchd)
91
83
  launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
92
84
  # unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
85
+
86
+ # Linux (systemd user unit)
87
+ systemctl --user enable --now safekeep
88
+ # status with: systemctl --user status safekeep
89
+ # logs with: journalctl --user -u safekeep -f
90
+ # unload with: systemctl --user disable --now safekeep
91
+ # start at boot without a login session: loginctl enable-linger $USER
93
92
  ```
94
93
 
95
- Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
96
- interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
94
+ On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
95
+ Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
96
+ Linux the same command checks the `inotify` watch limit instead.
97
97
 
98
98
  ## Quickstart
99
99
 
@@ -157,7 +157,7 @@ safekeep <command> [--config PATH] [-v]
157
157
  | `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
158
158
  | `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
159
159
  | `status` | read-only: config, sources, discovered projects with N rules, destination states |
160
- | `doctor` | diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
160
+ | `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
161
161
 
162
162
  ## Tests
163
163
 
@@ -165,9 +165,12 @@ safekeep <command> [--config PATH] [-v]
165
165
  python3 -m unittest discover -s tests
166
166
  ```
167
167
 
168
- Stdlib (`unittest`) suite, zero dependencies: 156 tests covering the matcher,
169
- config, atomic copy, volumes, daemon, auto-discovery and CLI. `fswatch` is not
170
- needed to run the tests.
168
+ Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
169
+ config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
170
+ portability layer (platform helper, per-OS fswatch monitor, systemd template).
171
+ `fswatch` is not needed to run the suite — the few `doctor` checks that talk to
172
+ the real binary are skipped when it's missing — but CI installs it so the
173
+ Linux job exercises the real `inotify` monitor.
171
174
 
172
175
  ## Documentation
173
176
 
@@ -6,25 +6,31 @@ build-backend = "setuptools.build_meta"
6
6
  [project]
7
7
  name = "safekeep"
8
8
  dynamic = ["version"]
9
- description = "Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes"
9
+ description = "Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes"
10
10
  readme = "README.md"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  requires-python = ">=3.9"
14
- keywords = ["backup", "macos", "fswatch", "sync", "allow-list", "launchd"]
14
+ keywords = ["backup", "macos", "linux", "fswatch", "sync", "allow-list", "launchd", "systemd"]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
17
17
  "Environment :: Console",
18
18
  "Intended Audience :: System Administrators",
19
19
  "Operating System :: MacOS",
20
+ "Operating System :: POSIX",
21
+ "Operating System :: POSIX :: Linux",
20
22
  "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.14",
21
26
  "Topic :: System :: Archiving :: Backup",
22
27
  ]
23
28
  # zero runtime dependencies: solo stdlib (fswatch è un binario esterno)
24
29
 
25
30
  [project.urls]
26
- Homepage = "https://gitlab.com/foxhound87/safekeep"
27
- Repository = "https://gitlab.com/foxhound87/safekeep"
31
+ Repository = "https://github.com/foxhound87/safekeep"
32
+ Homepage = "https://foxhound87.github.io/safekeep/"
33
+ Documentation = "https://foxhound87.github.io/safekeep/"
28
34
 
29
35
  [project.scripts]
30
36
  safekeep = "safekeep.cli:main"
@@ -0,0 +1,6 @@
1
+ """safekeep — motore di backup event-driven per macOS e Linux (vedi SPEC.md)."""
2
+ import logging
3
+
4
+ logging.getLogger('safekeep').addHandler(logging.NullHandler())
5
+
6
+ __version__ = '0.3.1'
@@ -1,7 +1,7 @@
1
1
  """CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
2
2
 
3
3
  Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
4
- shim che usano launchd e i test.
4
+ shim che usano launchd/systemd e i test.
5
5
  """
6
6
  import argparse
7
7
  import os
@@ -12,13 +12,24 @@ import sys
12
12
  from safekeep.config import ConfigError, discover_projects, parse_sync
13
13
  from safekeep.copier import TMP_INFIX
14
14
  from safekeep.daemon import Daemon, setup_logging
15
+ from safekeep.platform import (
16
+ INOTIFY_MIN_WATCHES,
17
+ LOG_PATH,
18
+ fswatch_monitor,
19
+ inotify_limit,
20
+ install_hint,
21
+ is_darwin,
22
+ is_linux,
23
+ linger_enabled,
24
+ recent_permission_errors,
25
+ )
15
26
  from safekeep.volumes import dest_state
16
27
 
17
28
  # Radice del repo quando si gira da checkout; in un install pip punta altrove
18
29
  # (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
19
30
  REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
31
  EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
21
- REPO_URL = 'https://gitlab.com/foxhound87/safekeep'
32
+ REPO_URL = 'https://github.com/foxhound87/safekeep'
22
33
 
23
34
 
24
35
  def build_parser():
@@ -29,7 +40,7 @@ def build_parser():
29
40
 
30
41
  parser = argparse.ArgumentParser(
31
42
  prog='safekeep',
32
- description='safekeep — backup event-driven per macOS (vedi SPEC.md)')
43
+ description='safekeep — backup event-driven per macOS e Linux (vedi SPEC.md)')
33
44
  sub = parser.add_subparsers(dest='comando', required=True)
34
45
  sub.add_parser('run', parents=[common],
35
46
  help='daemon: reconcile iniziale + watch fswatch + dispatch eventi + timer 24h')
@@ -45,7 +56,10 @@ def build_parser():
45
56
  sub.add_parser('status', parents=[common],
46
57
  help='config, progetti e stato delle dest — sola lettura')
47
58
  sub.add_parser('doctor', parents=[common],
48
- help='diagnostica: config, fswatch, TCC, plist — exit ≠ 0 se un check fatale fallisce')
59
+ help='diagnostica: config, fswatch + monitor di piattaforma, python, '
60
+ 'errori di permesso recenti nel log, TCC/plist (macOS), '
61
+ 'inotify/systemd/linger (Linux) — exit ≠ 0 se un check '
62
+ 'fatale fallisce')
49
63
  return parser
50
64
 
51
65
 
@@ -186,7 +200,7 @@ def cmd_doctor(args):
186
200
 
187
201
  exe = shutil.which('fswatch')
188
202
  if exe is None:
189
- out(False, 'fswatch non trovato — installa con: brew install fswatch',
203
+ out(False, f'fswatch non trovato — installa con: {install_hint()}',
190
204
  is_fatal=True)
191
205
  else:
192
206
  version = None
@@ -200,31 +214,64 @@ def cmd_doctor(args):
200
214
  out(version is not None,
201
215
  f'fswatch: {version}' if version else 'fswatch --version senza output',
202
216
  is_fatal=True)
217
+ # monitor atteso per piattaforma (SPEC.md §14.2/§14.4)
218
+ expected = fswatch_monitor()
203
219
  try:
204
220
  monitors = subprocess.run([exe, '-M'], capture_output=True,
205
221
  text=True, timeout=15)
206
- has_fsevents = 'fsevents_monitor' in monitors.stdout
222
+ present = expected in monitors.stdout
207
223
  except (OSError, subprocess.SubprocessError):
208
- has_fsevents = False
209
- out(has_fsevents,
210
- 'fswatch monitor: fsevents_monitor presente' if has_fsevents else
211
- 'fswatch monitor: fsevents_monitor ASSENTE in `fswatch -M`',
224
+ present = False
225
+ out(present,
226
+ f'fswatch monitor: {expected} presente' if present else
227
+ f'fswatch monitor: {expected} ASSENTE in `fswatch -M`',
212
228
  is_fatal=True)
213
229
 
214
230
  out(sys.version_info >= (3, 9),
215
231
  f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
216
232
 
217
- docs = os.path.expanduser('~/Documents')
218
- if not os.path.isdir(docs):
219
- out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
233
+ # Errori di permesso recenti nel log corrente (SPEC.md §14.4): sintomo
234
+ # delle ultime 24h, non stato del sistema → sempre NON fatale
235
+ log = os.path.expanduser(LOG_PATH)
236
+ recent = recent_permission_errors()
237
+ if recent is None:
238
+ out(True, f'errori di permesso recenti: log assente — check saltato ({log})')
239
+ elif recent[0] > 0:
240
+ out(False, f'errori di permesso recenti: {recent[0]} nelle ultime 24h '
241
+ f'(ultimo: {recent[1]}) — log: {log} — warning non fatale: '
242
+ 'vedi i pending con `safekeep sync-once --dry-run`, poi '
243
+ 'riconcedere i permessi al volume (FDA/Full Disk Access)')
220
244
  else:
221
- try:
222
- os.listdir(docs)
223
- except PermissionError:
224
- out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
225
- '(Full Disk Access) per i progetti in cartelle protette')
245
+ out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
246
+
247
+ # Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
248
+ # (SPEC.md §14.4) — informativo, NON fatale
249
+ if is_linux():
250
+ limit = inotify_limit()
251
+ if limit is None:
252
+ out(True, 'inotify: limite di watch non leggibile — check saltato')
253
+ elif limit < INOTIFY_MIN_WATCHES:
254
+ out(False, f'inotify: fs.inotify.max_user_watches={limit} '
255
+ f'(< {INOTIFY_MIN_WATCHES}) — su alberi grandi (~25k file) i '
256
+ 'watch finiscono: sudo sysctl -w fs.inotify.max_user_watches=524288 '
257
+ '— warning non fatale')
258
+ else:
259
+ out(True, f'inotify: fs.inotify.max_user_watches={limit} '
260
+ f'(≥ {INOTIFY_MIN_WATCHES})')
261
+
262
+ # TCC (Transparency, Consent and Control) è un meccanismo di macOS (SPEC.md §11)
263
+ if is_darwin():
264
+ docs = os.path.expanduser('~/Documents')
265
+ if not os.path.isdir(docs):
266
+ out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
226
267
  else:
227
- out(True, 'TCC: ~/Documents leggibile')
268
+ try:
269
+ os.listdir(docs)
270
+ except PermissionError:
271
+ out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
272
+ '(Full Disk Access) per i progetti in cartelle protette')
273
+ else:
274
+ out(True, 'TCC: ~/Documents leggibile')
228
275
 
229
276
  if cfg is not None:
230
277
  residui = [os.path.join(dirpath, name)
@@ -236,19 +283,48 @@ def cmd_doctor(args):
236
283
  'residui *.safekeep.tmp.*: nessuno' if not residui else
237
284
  'residui *.safekeep.tmp.*: ' + ', '.join(residui))
238
285
 
239
- plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
240
- if not os.path.exists(plist):
241
- out(True, 'plist non installato (nessun agent da validare)')
242
- elif shutil.which('plutil') is None:
243
- out(True, 'plutil assente — lint del plist saltato')
286
+ if is_darwin():
287
+ plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
288
+ if not os.path.exists(plist):
289
+ out(True, 'plist non installato (nessun agent da validare)')
290
+ elif shutil.which('plutil') is None:
291
+ out(True, 'plutil assente — lint del plist saltato')
292
+ else:
293
+ try:
294
+ lint = subprocess.run(['plutil', '-lint', plist],
295
+ capture_output=True, text=True, timeout=15)
296
+ detail = (lint.stdout + lint.stderr).strip() or plist
297
+ out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
298
+ except (OSError, subprocess.SubprocessError) as e:
299
+ out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
244
300
  else:
245
- try:
246
- lint = subprocess.run(['plutil', '-lint', plist],
247
- capture_output=True, text=True, timeout=15)
248
- detail = (lint.stdout + lint.stderr).strip() or plist
249
- out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
250
- except (OSError, subprocess.SubprocessError) as e:
251
- out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
301
+ # Linux: user unit systemd al posto del plist (SPEC.md §14.3)
302
+ unit = os.path.expanduser('~/.config/systemd/user/safekeep.service')
303
+ if not os.path.exists(unit):
304
+ out(True, 'unit systemd non installata (nessun agent da validare)')
305
+ else:
306
+ try:
307
+ with open(unit, encoding='utf-8') as fh:
308
+ text = fh.read()
309
+ except (OSError, UnicodeError) as e:
310
+ out(False, f'unit systemd illeggibile: {unit}: {e}', is_fatal=True)
311
+ else:
312
+ if '__REPO__' in text or '__PYTHON__' in text:
313
+ out(False, f'unit systemd: {unit} contiene ancora i placeholder '
314
+ 'del template — rilancia install.sh', is_fatal=True)
315
+ else:
316
+ out(True, f'unit systemd: {unit}')
317
+ # linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
318
+ enabled = linger_enabled()
319
+ if enabled is None:
320
+ out(True, 'linger: non verificabile (/var/lib/systemd/linger '
321
+ 'assente) — check saltato')
322
+ elif enabled:
323
+ out(True, 'linger: abilitato (agent al boot anche senza login)')
324
+ else:
325
+ out(False, "linger: NON abilitato — l'agent non partirà al boot "
326
+ 'senza login → loginctl enable-linger $USER — '
327
+ 'warning non fatale')
252
328
 
253
329
  return 1 if fatal else 0
254
330
 
@@ -4,7 +4,8 @@
4
4
  Un solo processo, nessun thread: `step()` fa `select` su stdout di fswatch con
5
5
  timeout pari alla scadenza più vicina (reconcile giornaliero, retry dest
6
6
  absent, respawn fswatch). Le regex `-e` sono un pre-filtro grezzo: la verità
7
- su cosa copiare resta al matcher Python (SPEC.md §6.4).
7
+ su cosa copiare resta al matcher Python (SPEC.md §6.4). Il monitor di fswatch
8
+ viene da `safekeep.platform` (SPEC.md §14.1).
8
9
  """
9
10
  import logging
10
11
  import logging.handlers
@@ -26,6 +27,7 @@ from .config import (
26
27
  )
27
28
  from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
28
29
  from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
30
+ from .platform import LOG_PATH, fswatch_monitor, init_system
29
31
  from .volumes import backoff_schedule, dest_state, partition_dests
30
32
 
31
33
  log = logging.getLogger('safekeep')
@@ -34,7 +36,7 @@ FLOOD_LIMIT = 5000 # path per progetto in un batch → collapse a reconci
34
36
  DEDUP_WINDOW = 2.0 # secondi: stessa path entro la finestra → un solo evento
35
37
  RECONCILE_INTERVAL = 24 * 3600
36
38
  DEATH_WINDOW = 60 # finestra di conteggio delle morti di fswatch
37
- MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (launchd ci riprova)
39
+ MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (l'init system ci riprova)
38
40
  RESPAWN_CAP = 60 # backoff respawn 1 → 60s
39
41
 
40
42
  SKIP = 'skip'
@@ -269,7 +271,7 @@ def setup_logging(level='info', logfile=None):
269
271
  logger._safekeep_handlers = True
270
272
  fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
271
273
  if logfile is None:
272
- logfile = os.path.expanduser('~/.local/state/safekeep/safekeep.log')
274
+ logfile = os.path.expanduser(LOG_PATH)
273
275
  try:
274
276
  os.makedirs(os.path.dirname(logfile), exist_ok=True)
275
277
  handler = logging.handlers.RotatingFileHandler(
@@ -409,8 +411,9 @@ class Daemon:
409
411
  # --- fswatch ---------------------------------------------------------
410
412
 
411
413
  def fswatch_argv(self):
412
- """`fswatch -0 -m fsevents_monitor -r -l 1.0 -e <regex> -- <roots>` (§6.4)."""
413
- argv = ['fswatch', '-0', '-m', 'fsevents_monitor', '-r', '-l', '1.0']
414
+ """`fswatch -0 -m <monitor> -r -l 1.0 -e <regex> -- <roots>` (§6.4, §14.2):
415
+ il monitor è dichiarato per piattaforma, mai il default implicito."""
416
+ argv = ['fswatch', '-0', '-m', fswatch_monitor(), '-r', '-l', '1.0']
414
417
  for regex in self.exclude_regexes():
415
418
  argv += ['-e', regex]
416
419
  return argv + ['--'] + self.discovery_roots()[0]
@@ -462,8 +465,8 @@ class Daemon:
462
465
  self.deaths.append(now)
463
466
  if len(self.deaths) >= MAX_DEATHS:
464
467
  log.error('fswatch morto %d volte in %ds: esco con codice != 0 '
465
- '(launchd con KeepAlive ci riproverà)',
466
- len(self.deaths), DEATH_WINDOW)
468
+ '(%s ci riproverà)',
469
+ len(self.deaths), DEATH_WINDOW, init_system())
467
470
  self.fatal = True
468
471
  return
469
472
  if now - self.last_spawn >= DEATH_WINDOW: