kimal3 0.9.1 → 0.9.3

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.
@@ -29,7 +29,8 @@ the cases of this command.
29
29
  step.** You open a file from the recording for the person only in Notepad, and in a way that
30
30
  your call returns at once: in Bash `cmd //c start "" notepad "<path>"`, in PowerShell
31
31
  `Start-Process notepad -ArgumentList '"<path>"'`. A bare `notepad "<path>"` blocks until the
32
- window is closed again. Never in an IDE or an editor that Claude Code is attached to (selected
32
+ window is closed again. On the Mac you open it in TextEdit: `open -e "<path>"` — that returns at
33
+ once. Never in an IDE or an editor that Claude Code is attached to (selected
33
34
  lines end up in the conversation there by themselves).
34
35
  3. **A failure never looks like a success.** If a `kimal3 show …` doesn't end with `0`, say
35
36
  honestly what its message says, and don't carry on as if it had worked.
@@ -38,6 +39,10 @@ Every `kimal3 show …` runs **in the project folder** (step 3) — the program
38
39
  through the folder it is started in. If a recording lies in another project, change into it first:
39
40
  the project folder is the part of the path before `material/aufnahmen/`.
40
41
 
42
+ If Claude Code's sandbox is switched on, you run every `kimal3 show …` **outside the sandbox** (in
43
+ the Bash tool with `dangerouslyDisableSandbox`): the program writes to `~/.kimal3` and `~/.local`,
44
+ and that can fail inside the sandbox.
45
+
41
46
  ## 1. Arrive and look
42
47
 
43
48
  First run `kimal3 show status`. It only reads. **Don't** read the “Set up:” block out to the
@@ -46,7 +51,8 @@ person — just note whether anything in it is missing (a line with “missing
46
51
 
47
52
  | The output says | What you do |
48
53
  |---|---|
49
- | `kimal3 show` is Windows-only for now; the Mac follows. | Say exactly that, kindly: showing a task doesn't work on this computer yet, the Mac is coming. Offer that the person describes the task in words instead. The command ends here. |
54
+ | “`kimal3 show` is available on Windows and the Mac.” (exit code 1) | Say exactly that, kindly: showing a task doesn't work on this computer, only on Windows and on the Mac. Offer that the person describes the task in words instead. The command ends here. |
55
+ | “Recording needs macOS 13 or later — this Mac has macOS 12.” or, if the version can't be read, “Recording needs macOS 13 or later.” (exit code 1) | Say exactly that, kindly: on this Mac, showing a task only works with macOS 13 or later. Offer that the person describes the task in words instead. The command ends here. |
50
56
  | “I can't tell whether a recording is running.” (exit code 1) | Say honestly that you can't tell right now. **Start nothing.** Offer to look again in a minute. |
51
57
  | “Running since 09:12 (3 minutes), folder `…`, stops at 09:27 at the latest.” | A recording from an earlier session is still running. If the folder is a full path outside this project, first change into its project folder. Then ask: “There's still a recording running from 09:12 — are you through with showing the task? Then I'll stop it now.” Yes → step 8. |
52
58
  | “Running (started in another session).” | A recording is running that this project can't match. Say so, **don't start a second one**, and suggest checking with `kimal3 show status` once it has ended. |
@@ -54,6 +60,29 @@ person — just note whether anything in it is missing (a line with “missing
54
60
  | “No recording is running.” followed by “The last one (`…`) broke off.” | Kindly: the last recording broke off in the middle — the person did nothing wrong. Offer to show the task again. |
55
61
  | “No recording is running.” | Carry on. |
56
62
 
63
+ **Only on the Mac** there is one more line before the last one: “Permissions go to: …”. That is how
64
+ you know, for the whole conversation, that you are on a Mac. Remember the name after it (usually
65
+ “Terminal”) — below it is called **<App>**.
66
+
67
+ - **“Permissions go to: Claude (desktop app)”** → say this **first**, before anything else:
68
+
69
+ > “On the Mac, recording works reliably only from Terminal: in this app macOS sometimes doesn't
70
+ > ask for the permissions at all, and after every Claude update you'd have to grant them again.
71
+ > Shall I open Terminal for you in the right folder? There you type `claude` and then `/k3show` —
72
+ > that's a new conversation, just tell me the task there again. Or shall we try it here anyway?”
73
+
74
+ **Yes** → `open -a Terminal "<project folder>"` (if there is no project yet, the folder the session
75
+ is in right now). Say that Terminal is open now; the command ends here. **“Here anyway”** → carry
76
+ on as usual; <App> is then “Claude”.
77
+ - **“Permissions go to: nobody — macOS can't grant permissions over SSH”** → say exactly that: over
78
+ a remote connection macOS can't grant the permissions for screen, keys and microphone. Suggest
79
+ opening a Terminal on the Mac itself and starting `claude` and `/k3show` there. The command ends
80
+ here.
81
+ - **“Permissions go to: the app you started Claude from”** → ask **right here**, once, what the app
82
+ Claude runs in is called (suggestion: “Terminal?”), and use the answer as <App> — so the right name
83
+ is already in the announcement of step 4. Only after that comes the question about the task (one
84
+ question at a time).
85
+
57
86
  The last line says whether the folder is right — remember it for step 3:
58
87
  “Recordings go here: …” (right) · “Switch into a project first …” (the session is in the
59
88
  workbench) · “The project folder is nested too deeply for the recorder …” (the path is too long).
@@ -90,28 +119,135 @@ question (step 11). A rough sentence is enough; don't dig.
90
119
 
91
120
  ## 4. Setting up — only if something is missing
92
121
 
93
- If nothing is missing, skip this step without a word about it. If something is missing, say
94
- **exactly this first** and wait for the answer:
122
+ If nothing is missing, skip this step without a word about it (on the Mac carry on with step 4b).
123
+ If something is missing, say **exactly this first** and wait for the answer:
95
124
 
96
125
  > “For this I'll set up the recorder once — a free program that records screen, sound, clicks
97
126
  > and keys. About 2.1 GB, 10 to 20 minutes depending on your connection, only in your user
98
127
  > folder, no admin rights. Because it records keystrokes, your company's antivirus may flag it;
99
128
  > on a work computer, check with IT first if in doubt. Shall I?”
100
129
 
130
+ **On the Mac** you say this sentence instead, with <App> from step 1:
131
+
132
+ > “For this I'll set up the recorder once — a free program that records screen, sound, clicks and
133
+ > keys. About 2 GB, 10 to 20 minutes depending on your connection, only in your user folder. After
134
+ > that you switch on four permissions for <App> in System Settings — I'll show you each one. macOS
135
+ > asks for an admin password along the way (on your own Mac that's your login password), and <App>
136
+ > restarts once. On a work Mac this often only works with IT. Shall I?”
137
+
101
138
  - **Yes:** start `kimal3 show setup` **in the background** — it can take longer than the ten
102
139
  minutes a command may run in the foreground. Say that it is running and the person can do
103
140
  something else meanwhile. **Wait for the notice that it has finished** — don't check in between,
104
141
  and don't sleep in a loop. After that:
105
142
  - exit code `0` → `kimal3 show status`; if all four parts are there (uv, recorder, FFmpeg,
106
- speech model), carry on with step 5.
143
+ speech model), carry on with step 5 (on the Mac with step 4b).
107
144
  - exit code `1` (“Step … didn't work.”) → name that step, and offer a ready-made
108
- message to IT: **what** (uv from Astral · the recorder openadapt-capture 1.3.0, MIT licence,
145
+ message to IT (on the Mac the one below): **what** (uv from Astral · the recorder openadapt-capture 1.3.0, MIT licence,
109
146
  with Python 3.12 · FFmpeg · the speech model large-v3-turbo from Hugging Face), **what for**
110
147
  (showing a work task once on screen and evaluating it on the computer), **where from**
111
148
  (astral.sh or winget, pypi.org, huggingface.co), **which folders** (only the user folder, no
112
149
  admin rights).
113
150
  - **No:** “No problem — you can do it any time later with `/k3show`.” The command ends here.
114
151
 
152
+ **The message to IT on the Mac:** **what** (uv from Astral, or via Homebrew if it is there · the
153
+ recorder openadapt-capture 1.3.0, MIT licence, with Python 3.12 and the add-on
154
+ pyobjc-framework-AVFoundation 12.2.2 · FFmpeg · the speech model large-v3-turbo from Hugging Face),
155
+ **what for** (showing a work task once on screen and evaluating it on the computer), **where from**
156
+ (astral.sh or Homebrew, pypi.org, github.com, huggingface.co), **which folders** (only the user
157
+ folder) — and on top **the four permissions** under System Settings → Privacy & Security (Screen &
158
+ System Audio Recording, Input Monitoring, Accessibility, Microphone), **for which app** (<App>), and
159
+ that a standard account may not switch on two of them itself (Screen & System Audio Recording,
160
+ Input Monitoring).
161
+
162
+ ## 4b. Permissions — only on the Mac
163
+
164
+ On Windows you skip this step. On the Mac (the status has the line “Permissions go to …”) it comes
165
+ **before every** briefing in step 5: without the four permissions the recorder records nothing
166
+ useful. Run `kimal3 show check`.
167
+
168
+ | The output says | What you do |
169
+ |---|---|
170
+ | “All four permissions for <App> are in place.” (exit code 0) | Carry on with step 5 without a word about it. |
171
+ | “Permissions for <App>:” and below it, per permission, “in place”, “missing” or “can't tell” (exit code 1) | Not a failure, but the list of what is still missing → see below. Treat a line with “can't tell” like “missing”, but never call it “in place” or “missing” to the person. |
172
+ | “I can't tell whether the permissions are in place.” (exit code 1) | Say honestly that you can't tell right now. Suggest going through the four switches anyway (below) — it can't do any harm. |
173
+ | “The recorder isn't set up yet: `kimal3 show setup`.” | Back to step 4. |
174
+
175
+ If “Permissions for” is followed only by “this app”, use as <App> the name the person gave you in
176
+ step 1; if you don't have one yet, ask once now (suggestion: “Terminal?”).
177
+
178
+ **If something is missing**, say once beforehand (if fewer than four are missing, say the number
179
+ instead of “four”):
180
+
181
+ > “I'll now show you four switches, one after another. If macOS offers ‘Quit & Reopen’ along the
182
+ > way, click ‘Later’ until all four are on — then we restart once.”
183
+
184
+ Then **for each missing permission**, in the order of the list, one after the other:
185
+
186
+ 1. **One sentence on what it is:**
187
+ Screen & System Audio Recording — so the recorder sees what happens on the screen · Input
188
+ Monitoring — so it can write down your clicks and keys · Accessibility — so it can read the names
189
+ of the buttons and fields you click · Microphone — so it hears what you say.
190
+ 2. `kimal3 show check --ask <permission>` (`screen`, `input`, `accessibility` or `microphone`) — with
191
+ this macOS puts <App> into the list for this permission. Say beforehand: for the microphone macOS
192
+ asks itself, click “Allow” there; if macOS shows a window for the others, any button in it is
193
+ fine — you'll open the page yourself in a moment. The output is the list again; read **only the
194
+ line of this permission** in it. If it says “in place” (often for the microphone after “Allow”),
195
+ it's done → the next one.
196
+ 3. `kimal3 show check --open <permission>` opens exactly this page of System Settings (“I opened
197
+ ‘…’ in System Settings.”). If it says “System Settings couldn't be opened.”, name the way by hand:
198
+ Apple menu → System Settings → Privacy & Security → the name of the permission.
199
+ 4. **The click path:** “In the list, switch on ‘<App>’.” For the first one, Screen & System Audio
200
+ Recording, more precisely: “in the **upper** section ‘Screen & System Audio Recording’, not under
201
+ ‘System Audio Recording Only’”. If <App> is missing from the list: “Click the plus at the bottom →
202
+ Applications → Utilities → Terminal” (for another app: Applications → <App>).
203
+ For **Screen & System Audio Recording** and **Input Monitoring** add: “macOS asks for an admin
204
+ password along the way — on your own Mac that's your login password.” If the person has none (work
205
+ Mac, standard account): offer the message to IT from step 4; the command ends here.
206
+ 5. Wait until the person says the switch is on. **Don't check now** — macOS often only reports a
207
+ new permission after the restart. Carry on with the next one.
208
+
209
+ **Once all are on:** `kimal3 show check` again.
210
+
211
+ - “All four permissions for <App> are in place.” → carry on with step 5.
212
+ - Still “missing” (or “can't tell”) for a permission the person has switched on → **first the
213
+ restart** (below). Do **not** send them into the settings again.
214
+ - “missing” for one you haven't gone through yet → back to it, point 1.
215
+
216
+ ### The restart
217
+
218
+ **Beforehand**, put the way back into the clipboard — **always** with the folder in which **this
219
+ Claude session was started** (“Primary working directory” in your environment), **never** the
220
+ project folder, even if you are working there now: `claude --continue` only continues the
221
+ conversation that began in exactly that folder.
222
+
223
+ ```bash
224
+ printf '%s' "cd '<folder this Claude session was started in>' && claude --continue" | LC_CTYPE=UTF-8 pbcopy
225
+ ```
226
+
227
+ (If the folder name contains a `'`, write it as `'\''`. `LC_CTYPE=UTF-8` keeps umlauts and other
228
+ accented letters in the folder name intact.) **Also show** the same line `cd '…' && claude --continue`
229
+ visibly in the chat, as its own code block — if pasting doesn't work, the person types it from
230
+ there. Then say word for word:
231
+
232
+ > “Now quit Terminal completely once: ⌘ + Q (or in the menu ‘Terminal → Quit Terminal’). If
233
+ > Terminal asks whether to terminate running processes: ‘Terminate’. Then reopen Terminal (⌘ +
234
+ > Space, type ‘Terminal’, Enter), paste with ⌘ + V and press Enter. I've already copied the command
235
+ > for you — our conversation continues right here.”
236
+
237
+ Add in the same message: “Then just type ‘continue’, and I'll check whether the permissions are in
238
+ place now.” — in the **same** message as the sentence above: after the restart nothing new comes
239
+ from you until the person writes something. If <App> isn't “Terminal”, put in its name. If Claude runs in the desktop app, this way
240
+ doesn't fit: offer Terminal instead (sentence and `open -a Terminal` from step 1).
241
+
242
+ When the person is back, check — **from the project folder**, like every `kimal3 show …`:
243
+ `cd "<project folder>" && kimal3 show check`.
244
+
245
+ - “All four permissions for <App> are in place.” → carry on with step 5.
246
+ - Still “missing”: open exactly this page again (`--open`) and have them look whether the switch for
247
+ <App> is on. If it was off: have it switched on, then one more restart as above. If it was on: say
248
+ honestly that macOS doesn't accept the permission, and offer the message to IT from step 4. The
249
+ command ends here.
250
+
115
251
  ## 5. The briefing before the start
116
252
 
117
253
  Say these four points before anything records:
@@ -124,6 +260,11 @@ Say these four points before anything records:
124
260
  4. When you're done, come back here and type **“done”**; the recording stops by itself after 15
125
261
  minutes at the latest.
126
262
 
263
+ **On the Mac** point 1 says, instead of “switch on ‘Do not disturb’”: “switch on the ‘Do Not
264
+ Disturb’ Focus (Control Center → Focus)”. And a fifth point is added:
265
+
266
+ 5. If macOS asks midway whether <App> may keep recording: click ‘Allow’ and carry on.
267
+
127
268
  Then: “Type ‘go’ when you're ready.” — and wait.
128
269
 
129
270
  - **A clear go** — “go”, “ready”, “start” — → step 6.
@@ -146,6 +287,7 @@ explicitly wants it shorter.
146
287
  | “A recording is already running (started in another session).” | Ask: “There's still a recording running from another session — shall I stop it so that we can start again?” Yes → `kimal3 show stop`; if it says it can't match it here, say so honestly and suggest waiting until it stops by itself (`kimal3 show status`). |
147
288
  | “I can't tell whether a recording is running — so I won't start a second one.” | Say it honestly, force nothing, look again later. |
148
289
  | “The recorder isn't set up yet: `kimal3 show setup`.” | Back to step 4. |
290
+ | “The recording didn't start: the … permission is missing — `kimal3 show check` shows what's missing. Log: …” | Name the permission. **Don't open the log.** Back to step 4b. |
149
291
  | “The recording didn't start: <reason>. Log: …” | Name the reason. **Don't open the log** — it contains window titles. If “Whether the recorder is still running: `kimal3 show status`.” follows, run `kimal3 show status` and say what it reports. Offer another attempt. |
150
292
  | “Switch into a project first …” · “The project folder is nested too deeply …” | Back to step 3. |
151
293
 
@@ -174,7 +316,7 @@ before the 10 minutes are up.
174
316
  | “No recording is running.” | Check with `kimal3 show status`. If it says “The last one (`…`) stopped by itself after … minutes.”, carry on with step 9; otherwise say honestly that there is no recording. |
175
317
  | “I can't tell whether a recording is running.” | Say it honestly — **never** “stopped”. Offer to look again in a minute with `kimal3 show status`. |
176
318
  | “The recording wasn't closed cleanly: <reason>.” (exit code 1) | Name the reason and offer a new recording. |
177
- | “The recording broke off: <reason>. …” (exit code 1) | Kindly: the person did nothing wrong. Name the reason and offer a new recording. |
319
+ | “The recording broke off: <reason>. …” (exit code 1) | Kindly: the person did nothing wrong. Name the reason and offer a new recording (if the reason names a permission: step 4b first). |
178
320
  | “The recording couldn't be stopped …” · “A recording is running that I can't match here …” | Run `kimal3 show status` and say honestly what it reports — **never** “stopped”. If the session is in the wrong folder, change into the recording's project. |
179
321
 
180
322
  ## 9. Evaluating — on the person's computer
@@ -222,7 +364,7 @@ Then this question, word for word:
222
364
  > leave out the typed parts?”
223
365
 
224
366
  - **“Open it”:** in Notepad, as in rule 2 (`cmd //c start "" notepad "<full path>"` or
225
- `Start-Process`), with the full path to `zeitleiste.md` (the project folder plus the path from
367
+ `Start-Process`; on the Mac in TextEdit with `open -e "<full path>"`), with the full path to `zeitleiste.md` (the project folder plus the path from
226
368
  step 9). The person reads it themselves; you don't read along. Then ask the question about
227
369
  agreeing once more.
228
370
  - **“Without the typed parts”:** `kimal3 show evaluate --without-typed` — with the same
@@ -1,8 +1,11 @@
1
1
  # Gemeinsame Teile von werkzeug.py und starter.py: Dateinamen, Umgebung des Aufnehmers, Musterliste der Fehlergruende,
2
2
  # JSON lesen/schreiben, Stopp einer Sitzung. Kein eigener Einstieg; laeuft mit dem Python des Aufnehmers.
3
+ import contextlib
3
4
  import json
4
5
  import os
5
6
  import re
7
+ import sys
8
+ import threading
6
9
  import time
7
10
 
8
11
  # Der Aufnehmer bekommt einen eigenen Unterordner, in den sonst niemand schreibt: Beim Abschliessen versiegelt er JEDE
@@ -14,14 +17,30 @@ STARTER = "starter.json"
14
17
  PROTOKOLL = "aufnahme.log"
15
18
  ABBRECHEN = "abbrechen"
16
19
  ZUSTAND_AUFNEHMER = "capture-state.json"
17
- UMGEBUNG = {
20
+ # Die Plattform, auf der dieses Python laeuft. Tests setzen sie (gemeinsam.PLATTFORM, starter.PLATTFORM); die Rechnungen
21
+ # darunter nehmen sie als Parameter, damit beide Seiten ohne Mac pruefbar sind.
22
+ PLATTFORM = sys.platform
23
+ ANFRAGE_WARTE_SEK = 60 # so lange wartet die Mikrofon-Anfrage auf die Antwort des Menschen im Systemdialog
24
+ _UMGEBUNG = {
18
25
  "STOP_SEQUENCES": '[["o","a",".","s","t","o","p"]]', # die Strg-Folge des Aufnehmers beendet sonst bei Strg+C (B1)
19
26
  "RECORD_AUDIO_RETAIN_WAVEFORM": "true",
20
27
  "PYTHONUNBUFFERED": "1",
21
28
  "PYTHONUTF8": "1",
22
29
  }
23
30
 
24
- # Zwilling von GRUENDE in cli/src/zeigen-rechnung.ts -- gleiche Reihenfolge, gleiche Schalter (der Test vergleicht beide).
31
+
32
+ def umgebung(plattform: str) -> dict:
33
+ """Umgebung des Aufnehmers. Auf dem Mac zusaetzlich ein FESTER Steuerordner im Heim: Ohne ihn legt der Aufnehmer ihn
34
+ unter $TMPDIR an, und eine zweite Claude-Sitzung (anderes $TMPDIR) fande die laufende Aufnahme nicht."""
35
+ env = dict(_UMGEBUNG)
36
+ if plattform == "darwin":
37
+ env["OPENADAPT_CAPTURE_RUNTIME_DIR"] = os.path.join(os.path.expanduser("~"), ".kimal3", "aufnehmer-steuerung")
38
+ return env
39
+
40
+
41
+ # Zwilling von GRUENDE_WINDOWS / GRUENDE_MAC in cli/src/zeigen-rechnung.ts -- gleiche Reihenfolge, gleiche Schalter (der
42
+ # Test vergleicht beide). Die Mac-Muster sind die festen Saetze des Aufnehmers, nie "Accessibility" allein: ein
43
+ # Fenstertitel "Accessibility Settings" ist kein Grund.
25
44
  GRUENDE = [
26
45
  (re.compile(r"FFmpegUnavailableError|ffmpeg.*not found", re.IGNORECASE), "ffmpeg-fehlt"),
27
46
  (re.compile(r"returned success without a non-empty output"), "pfad-zu-lang"),
@@ -29,10 +48,73 @@ GRUENDE = [
29
48
  (re.compile(r"DesktopCaptureError|screen layout"), "bildschirm-gesperrt"),
30
49
  (re.compile(r"did not become ready|did not start"), "startet-nicht"),
31
50
  ]
51
+ GRUENDE_MAC = [
52
+ (re.compile(r"FFmpegUnavailableError|ffmpeg.*not found", re.IGNORECASE), "ffmpeg-fehlt"),
53
+ (re.compile(r"denied global input observation"), "freigabe-eingabe"),
54
+ (re.compile(r"denied the ordered input barrier"), "freigabe-bedienungshilfen"),
55
+ (re.compile(r"disabled the input event tap"), "abgriff-abgeschaltet"),
56
+ (re.compile(r"PortAudio|No Default Input Device|sounddevice"), "kein-mikrofon"),
57
+ (re.compile(r"DesktopCaptureError|screen layout"), "bildschirm-gesperrt"),
58
+ (re.compile(r"did not become ready|did not start"), "startet-nicht"),
59
+ ]
60
+
61
+
62
+ def fehler_grund(protokoll: str, plattform: str) -> str:
63
+ """Alles ausser win32 hat die Mac-Liste (wie in TS). Die Plattform ist Pflicht: Still die eigene anzunehmen liess
64
+ eine Windows-Pruefung auf einem echten Mac mit der Mac-Liste laufen (Messung 2, docs/qa/2026-10-24-zeigen-mac-messungen.md)."""
65
+ gruende = GRUENDE if plattform == "win32" else GRUENDE_MAC
66
+ return next((code for muster, code in gruende if muster.search(protokoll)), "unbekannt")
32
67
 
33
68
 
34
- def fehler_grund(protokoll: str) -> str:
35
- return next((code for muster, code in GRUENDE if muster.search(protokoll)), "unbekannt")
69
+ def _wahrheitswert(wert) -> bool | None:
70
+ """Nur ein echter Wahrheitswert zaehlt; alles andere ist "weiss ich nicht" (nie "erteilt")."""
71
+ return wert if isinstance(wert, bool) else None
72
+
73
+
74
+ def _mikrofon_status(status) -> bool | None:
75
+ # AVAuthorizationStatus: 0 nicht gefragt, 1 eingeschraenkt, 2 abgelehnt, 3 erteilt. Alles andere: weiss ich nicht.
76
+ if isinstance(status, bool) or not isinstance(status, int):
77
+ return None
78
+ return status == 3 if status in (0, 1, 2, 3) else None
79
+
80
+
81
+ def _frage_mikrofon(av) -> None:
82
+ antwort = threading.Event()
83
+ av.AVCaptureDevice.requestAccessForMediaType_completionHandler_(av.AVMediaTypeAudio, lambda erteilt: antwort.set())
84
+ antwort.wait(ANFRAGE_WARTE_SEK)
85
+
86
+
87
+ def freigaben_abfragen(anfragen: str | None = None) -> dict:
88
+ """Fragt macOS, was dieser Prozess darf -- ohne etwas zu oeffnen (das Mikrofon wird nur nach seinem Status gefragt).
89
+ Jede Freigabe einzeln: fehlt das Modul oder wirft der Aufruf, ist genau diese `None` ("kann ich nicht feststellen").
90
+ `anfragen` loest VOR dem Abfragen genau diese eine Systemanfrage aus. Waehrend der Aufrufe ist stdout/stderr
91
+ umgeleitet: Eine fremde Zeile darf nie in die eine JSON-Zeile der Ausgabe geraten."""
92
+ def aus(name: str):
93
+ import importlib
94
+ return importlib.import_module(name)
95
+
96
+ def versuche(rechne):
97
+ try:
98
+ return rechne()
99
+ except Exception:
100
+ return None
101
+
102
+ ergebnis = {}
103
+ with open(os.devnull, "w") as stumm, contextlib.redirect_stdout(stumm), contextlib.redirect_stderr(stumm):
104
+ anfrage_fuer = {
105
+ "bildschirm": lambda: aus("Quartz").CGRequestScreenCaptureAccess(),
106
+ "eingabe": lambda: aus("Quartz").CGRequestListenEventAccess(),
107
+ "bedienungshilfen": lambda: (lambda aps: aps.AXIsProcessTrustedWithOptions({aps.kAXTrustedCheckOptionPrompt: True}))(aus("ApplicationServices")),
108
+ "mikrofon": lambda: _frage_mikrofon(aus("AVFoundation")),
109
+ }
110
+ if anfragen in anfrage_fuer: # eine gescheiterte Anfrage aendert nur die Antwort darunter
111
+ versuche(anfrage_fuer[anfragen])
112
+ ergebnis["bildschirm"] = _wahrheitswert(versuche(lambda: aus("Quartz").CGPreflightScreenCaptureAccess()))
113
+ ergebnis["eingabe"] = _wahrheitswert(versuche(lambda: aus("Quartz").CGPreflightListenEventAccess()))
114
+ ergebnis["bedienungshilfen"] = _wahrheitswert(versuche(lambda: aus("ApplicationServices").AXIsProcessTrusted()))
115
+ ergebnis["mikrofon"] = _mikrofon_status(versuche(
116
+ lambda: (lambda av: av.AVCaptureDevice.authorizationStatusForMediaType_(av.AVMediaTypeAudio))(aus("AVFoundation"))))
117
+ return ergebnis
36
118
 
37
119
 
38
120
  def schreibe_json_atomar(pfad: str, daten: dict) -> None:
package/zeigen/starter.py CHANGED
@@ -8,8 +8,8 @@ import sys
8
8
  import threading
9
9
  import time
10
10
 
11
- from gemeinsam import (ABBRECHEN, PROTOKOLL, ROH, STARTER, UMGEBUNG, _stoppe_sitzung, fehler_grund, lies_protokoll,
12
- sauber, schreibe_json_atomar)
11
+ from gemeinsam import (ABBRECHEN, PLATTFORM, PROTOKOLL, ROH, STARTER, _stoppe_sitzung, fehler_grund, freigaben_abfragen,
12
+ lies_protokoll, sauber, schreibe_json_atomar, umgebung)
13
13
 
14
14
  TAKT_SEK = 2.0 # so oft schaut der Starter nach Sitzung, Abbruch und Frist (Wanduhr, nicht ein Zeitgeber ueber 15 Minuten)
15
15
  NACHTOETEN_SEK = 0.5 # Pause zwischen den ersten Versuchen, einen Prozessbaum zu beenden
@@ -22,12 +22,16 @@ AUFNEHMEN = "import sys; from openadapt_capture.cli import record; record(sys.ar
22
22
 
23
23
 
24
24
  def _starte_kind(befehl: list, ordner: str, protokoll) -> subprocess.Popen:
25
+ env = dict(os.environ, **umgebung(PLATTFORM))
26
+ if PLATTFORM != "win32": # Mac: STARTUPINFO und CREATE_NEW_CONSOLE gibt es dort nicht; eine eigene Sitzung loest vom Starter
27
+ return subprocess.Popen(befehl, cwd=ordner, env=env, stdout=protokoll, stderr=subprocess.STDOUT,
28
+ start_new_session=True)
25
29
  # Eigene, VERSTECKTE Konsole: Sonst oeffnen python.exe, sein multiprocessing-Kind und FFmpeg je ein Fenster
26
30
  # (gemessen 18.10.: detached + windowsHide allein reicht nicht; starter-probe.py).
27
31
  si = subprocess.STARTUPINFO()
28
32
  si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
29
33
  si.wShowWindow = 0 # SW_HIDE
30
- return subprocess.Popen(befehl, cwd=ordner, env=dict(os.environ, **UMGEBUNG), stdout=protokoll,
34
+ return subprocess.Popen(befehl, cwd=ordner, env=env, stdout=protokoll,
31
35
  stderr=subprocess.STDOUT, startupinfo=si, creationflags=subprocess.CREATE_NEW_CONSOLE)
32
36
 
33
37
 
@@ -40,8 +44,15 @@ def _beende_baum(kind: subprocess.Popen) -> bool:
40
44
  baum += baum[0].children(recursive=True)
41
45
  except psutil.NoSuchProcess:
42
46
  baum = []
43
- subprocess.run(["taskkill", "/PID", str(kind.pid), "/T", "/F"], capture_output=True,
44
- creationflags=subprocess.CREATE_NO_WINDOW)
47
+ if PLATTFORM == "win32":
48
+ subprocess.run(["taskkill", "/PID", str(kind.pid), "/T", "/F"], capture_output=True,
49
+ creationflags=subprocess.CREATE_NO_WINDOW)
50
+ else: # Schnappschuss steht oben, VOR dem ersten Signal: ein beendeter Vater gaebe seine Kinder nicht mehr her
51
+ for p in baum:
52
+ try:
53
+ p.terminate()
54
+ except psutil.NoSuchProcess:
55
+ pass
45
56
  _, lebend = psutil.wait_procs(baum, timeout=10)
46
57
  for p in lebend:
47
58
  try:
@@ -118,7 +129,12 @@ def starte(konfig: dict, befehl: list | None = None) -> None:
118
129
  melde()
119
130
  kind = None
120
131
  try:
121
- python = os.path.join(os.path.dirname(sys.executable), "python.exe")
132
+ if PLATTFORM == "darwin": # ohne Freigabe nimmt der Aufnehmer nichts auf: gar nicht erst starten
133
+ freigaben = freigaben_abfragen()
134
+ for name in ("bildschirm", "eingabe", "bedienungshilfen"): # None ("weiss nicht") haelt nicht auf
135
+ if freigaben.get(name) is False:
136
+ return melde(zustand="fehlgeschlagen", grund=f"freigabe-{name}")
137
+ python = os.path.join(os.path.dirname(sys.executable), "python.exe") if PLATTFORM == "win32" else sys.executable
122
138
  roh = os.path.join(ordner, ROH) # nur der Aufnehmer schreibt dort (gemeinsam.ROH)
123
139
  os.makedirs(roh, exist_ok=True)
124
140
  with open(os.path.join(ordner, PROTOKOLL), "w", encoding="utf-8") as protokoll:
@@ -138,7 +154,7 @@ def starte(konfig: dict, befehl: list | None = None) -> None:
138
154
  if time.time() >= bericht["frist"]:
139
155
  if bericht["sitzung"] is None:
140
156
  _raeume_weg(kind, melde)
141
- return melde(zustand="fehlgeschlagen", grund=fehler_grund(lies_protokoll(ordner)))
157
+ return melde(zustand="fehlgeschlagen", grund=fehler_grund(lies_protokoll(ordner), PLATTFORM))
142
158
  try:
143
159
  _mit_herzschlag(lambda: _stoppe_sitzung(bericht.get("sitzung"), BREMSE_STOPP_SEK), melde)
144
160
  except Exception:
@@ -160,4 +176,4 @@ def _ende(ordner: str, sitzung: str | None, art: str) -> dict:
160
176
  """Das Kind ist weg: sauber abgeschlossen -> `art`; mit Sitzung, aber nicht sauber -> abgebrochen; ohne -> fehlgeschlagen."""
161
177
  if sitzung is not None and sauber(ordner):
162
178
  return {"zustand": art, "grund": None}
163
- return {"zustand": "abgebrochen" if sitzung else "fehlgeschlagen", "grund": fehler_grund(lies_protokoll(ordner))}
179
+ return {"zustand": "abgebrochen" if sitzung else "fehlgeschlagen", "grund": fehler_grund(lies_protokoll(ordner), PLATTFORM)}
@@ -4,7 +4,8 @@
4
4
  # werkzeug.py starten <konfig.json> Starter mit Bremse (von Node mit pythonw abgehaengt gestartet, keine Ausgabe)
5
5
  # werkzeug.py stoppen <ordner> -> eine JSON-Zeile: Ergebnis und Zahlen
6
6
  # werkzeug.py zustand <ordner|-> -> eine JSON-Zeile: aktive Sitzungen des Aufnehmers, Bericht des Starters
7
- # werkzeug.py teile -> eine JSON-Zeile: was eingerichtet ist
7
+ # werkzeug.py teile -> eine JSON-Zeile: was eingerichtet ist (auf dem Mac auch: avfoundation)
8
+ # werkzeug.py freigaben [--anfragen <name>] -> eine JSON-Zeile: was macOS diesem Prozess erlaubt (Windows: null)
8
9
  # werkzeug.py zeitleiste <ordner> <de|en> [--ohne-getipptes] -> schreibt zeitleiste.md, eine JSON-Zeile mit Zahlen
9
10
  # werkzeug.py bild <ordner> <sekunden> -> bilder/<m-ss>.png, eine JSON-Zeile
10
11
  # werkzeug.py modell-laden -> eine JSON-Zeile (laedt large-v3-turbo, Netz)
@@ -15,6 +16,7 @@
15
16
  # festen Mustern durchsucht (fehler_grund), nie zurueckgegeben -- auch nicht auf stderr, auch nicht im Fehlerfall.
16
17
  # Gedeckt von `npm run check:zeigen-python` (scripts/zeigen/test_werkzeug.py). Spec 2026-10-18-k3zeigen-design.md.
17
18
  import argparse
19
+ import importlib.util
18
20
  import json
19
21
  import os
20
22
  import sqlite3
@@ -23,8 +25,8 @@ import time
23
25
 
24
26
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Nachbardateien auch unter -I/-P, die den Ordner weglassen
25
27
  sys.dont_write_bytecode = True # kein __pycache__ neben den Dateien: der Ordner geht so, wie er ist, ins npm-Paket
26
- from gemeinsam import (ABBRECHEN, ROH, STARTER, UMGEBUNG, ZUSTAND_AUFNEHMER, _stoppe_sitzung, # noqa: E402
27
- fehler_grund, lies_json, lies_protokoll, sauber)
28
+ from gemeinsam import (ABBRECHEN, PLATTFORM, ROH, STARTER, ZUSTAND_AUFNEHMER, _stoppe_sitzung, # noqa: E402
29
+ fehler_grund, freigaben_abfragen, lies_json, lies_protokoll, sauber, umgebung)
28
30
  from starter import starte # noqa: E402
29
31
 
30
32
  WARTEN_SEK = 15.0 # so lange wartet `stoppen` nach dem Stopp auf einen Starter ohne frischen Herzschlag
@@ -118,15 +120,15 @@ def stoppen(ordner: str) -> dict:
118
120
  if aktive is None or (bericht.get("sitzung") in aktive if zustand == "laeuft" else aktive):
119
121
  return antwort("unklar")
120
122
  if zustand == "startet": # ohne Sitzung gibt es keine Aufnahme, die man abschliessen koennte
121
- return antwort("abgebrochen", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
123
+ return antwort("abgebrochen", fehler_grund(lies_protokoll(ordner), PLATTFORM), aufnahme_zahlen(ordner))
122
124
  if zustand in ("abgebrochen", "fehlgeschlagen"):
123
- return antwort("abgebrochen", bericht.get("grund") or fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
125
+ return antwort("abgebrochen", bericht.get("grund") or fehler_grund(lies_protokoll(ordner), PLATTFORM), aufnahme_zahlen(ordner))
124
126
  if zustand not in ("gestoppt", "selbst-gestoppt", "laeuft"): # "laeuft" = Starter still, Sitzung nicht mehr aktiv
125
- return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
127
+ return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner), PLATTFORM), aufnahme_zahlen(ordner))
126
128
  ergebnis = "selbst-gestoppt" if zustand == "selbst-gestoppt" else "gestoppt"
127
129
  zahlen = aufnahme_zahlen(ordner, bericht.get("frist") if ergebnis == "selbst-gestoppt" else None)
128
130
  if zahlen is None or not sauber(ordner):
129
- return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), zahlen)
131
+ return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner), PLATTFORM), zahlen)
130
132
  return antwort(ergebnis, None, zahlen)
131
133
 
132
134
 
@@ -191,7 +193,10 @@ def teile() -> dict:
191
193
  aufnehmer = version("openadapt-capture")
192
194
  except PackageNotFoundError:
193
195
  aufnehmer = None
194
- return {"aufnehmer": aufnehmer, "ffmpeg": _ffmpeg_da(), "modell": modell_da(_hf_cache())}
196
+ daten = {"aufnehmer": aufnehmer, "ffmpeg": _ffmpeg_da(), "modell": modell_da(_hf_cache())}
197
+ if PLATTFORM == "darwin": # die Bruecke zu AVFoundation (Mikrofon-Status) kommt als eigenes Paket mit
198
+ daten["avfoundation"] = importlib.util.find_spec("AVFoundation") is not None
199
+ return daten
195
200
 
196
201
 
197
202
  def main(argv: list) -> int:
@@ -201,6 +206,7 @@ def main(argv: list) -> int:
201
206
  befehle.add_parser("stoppen").add_argument("ordner")
202
207
  befehle.add_parser("zustand").add_argument("ordner")
203
208
  befehle.add_parser("teile")
209
+ befehle.add_parser("freigaben").add_argument("--anfragen", choices=["bildschirm", "eingabe", "bedienungshilfen", "mikrofon"])
204
210
  zl = befehle.add_parser("zeitleiste")
205
211
  zl.add_argument("ordner")
206
212
  zl.add_argument("sprache", choices=["de", "en"])
@@ -210,7 +216,7 @@ def main(argv: list) -> int:
210
216
  bi.add_argument("sekunden", type=float)
211
217
  befehle.add_parser("modell-laden")
212
218
  args = parser.parse_args(argv)
213
- os.environ.update(UMGEBUNG)
219
+ os.environ.update(umgebung(PLATTFORM))
214
220
  try:
215
221
  if args.befehl == "starten":
216
222
  konfig = lies_json(args.konfig)
@@ -224,6 +230,8 @@ def main(argv: list) -> int:
224
230
  os.chdir(os.path.dirname(os.path.abspath(__file__)) if ordner == "-" or fehlt else ordner)
225
231
  if args.befehl == "stoppen":
226
232
  daten = stoppen(ordner)
233
+ elif args.befehl == "freigaben": # blockiert nicht laenger als freigaben_abfragen (Node: 20 s, mit --anfragen 90 s)
234
+ daten = {"freigaben": freigaben_abfragen(anfragen=args.anfragen) if PLATTFORM == "darwin" else None}
227
235
  elif args.befehl == "zustand":
228
236
  daten = zustand(None if ordner == "-" else ordner)
229
237
  elif args.befehl in ("zeitleiste", "bild", "modell-laden"):