From c4ed95ff6818a431df2ba5ca3f0a06fec59acf28 Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Tue, 2 Jun 2026 10:45:08 +0200 Subject: [PATCH] Add implementation plan for kydriv driver --- .../plans/2026-06-02-kydriv-implementation.md | 759 ++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-kydriv-implementation.md diff --git a/docs/superpowers/plans/2026-06-02-kydriv-implementation.md b/docs/superpowers/plans/2026-06-02-kydriv-implementation.md new file mode 100644 index 0000000..f7fd0a9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-kydriv-implementation.md @@ -0,0 +1,759 @@ +# kydriv Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a macOS CUPS printer driver for the Kyocera/TA 3505ci that restores department code (Kostenstelle) support on Apple Silicon, distributed as a signed `.pkg` installer. + +**Architecture:** A modified PPD (derived from the Intel `TA3505ci.PPD`) combined with a Python CUPS filter (`kyofilter`) that injects the department code as a PostScript `setmanagementnumber` command before `%%EndSetup`. Packaged with `pkgbuild`, signed with Developer ID, and notarized. + +**Tech Stack:** Python 3 (macOS built-in `/usr/bin/python3`), CUPS PPD format, pytest, macOS `pkgbuild`/`notarytool` + +--- + +## File Map + +| File | Purpose | +|------|---------| +| `ppd/TA3505ci_AS.ppd` | Modified PPD — no Intel plugins, points to `kyofilter` | +| `filter/kyofilter` | Python CUPS filter — injects department code into PS stream | +| `tests/conftest.py` | Makes `kyofilter` importable despite missing `.py` extension | +| `tests/test_kyofilter.py` | All unit tests | +| `installer/scripts/postinstall` | Sets permissions, restarts CUPS after install | +| `installer/build.sh` | Copies files into `installer/root/`, calls `pkgbuild` | +| `.gitignore` | Excludes `installer/root/` and `*.pkg` | + +--- + +### Task 1: Scaffolding + +**Files:** +- Create: `.gitignore` +- Create: directories `ppd/`, `filter/`, `tests/`, `installer/scripts/` + +- [ ] **Step 1: Create directories** + +```bash +mkdir -p ppd filter tests installer/scripts +``` + +- [ ] **Step 2: Create `.gitignore`** + +``` +__pycache__/ +.pytest_cache/ +*.pkg +installer/root/ +``` + +- [ ] **Step 3: Install pytest** + +```bash +pip3 install pytest +``` + +Expected: `Successfully installed pytest-...` (or `Requirement already satisfied`) + +- [ ] **Step 4: Commit** + +```bash +git add .gitignore +git commit -m "chore: project scaffolding" +``` + +--- + +### Task 2: PPD modification + +**Files:** +- Create: `ppd/TA3505ci_AS.ppd` + +The original Intel PPD has three problems: it references Intel-only filter binaries, it references Intel-only Cocoa plugins (APDialogExtension), and its KmManagment section has a fixed list of codes 0–30. We fix all three. + +- [ ] **Step 1: Copy original PPD** + +```bash +cp /Users/oliver/Downloads/TA3505ci.PPD ppd/TA3505ci_AS.ppd +``` + +- [ ] **Step 2: Remove Intel-only lines and update filter reference** + +```bash +# Remove the Intel pre-filter binary +sed -i '' '/^\*cupsPreFilter:/d' ppd/TA3505ci_AS.ppd + +# Point to our new Python filter instead of kyofilter_E +sed -i '' 's/kyofilter_E/kyofilter/' ppd/TA3505ci_AS.ppd + +# Remove all 11 APDialogExtension lines (Intel-only Cocoa print-dialog plugins) +sed -i '' '/^\*APDialogExtension:/d' ppd/TA3505ci_AS.ppd +``` + +Verify: +```bash +grep -c "APDialogExtension\|cupsPreFilter\|kyofilter_E" ppd/TA3505ci_AS.ppd +``` +Expected: `0` + +```bash +grep "cupsFilter" ppd/TA3505ci_AS.ppd +``` +Expected: `*cupsFilter: "application/vnd.cups-postscript 0 kyofilter"` + +- [ ] **Step 3: Replace KmManagment section** + +Run this Python script from the repo root (replaces the entire AccountingOptions group): + +```bash +python3 - << 'PYEOF' +import re, pathlib + +ppd = pathlib.Path("ppd/TA3505ci_AS.ppd").read_text(encoding="latin-1") + +new_section = ( + "*OpenGroup: AccountingOptions/Job Accounting\n\n" + "*% ---------------------------------------------------------\n" + "*% Kostenstelle (5-stellig) eintragen - ohne fuehrende Nullen.\n" + "*% Zeile kopieren und Zahl anpassen:\n" + "*% *KmManagment MG12345/Code 12345: \"\"\n" + "*% Zero-Padding auf 8 Stellen uebernimmt der Filter.\n" + "*% ---------------------------------------------------------\n" + "*OpenUI *KmManagment/Job Accounting: PickOne\n" + "*DefaultKmManagment: Default\n" + "*OrderDependency: 60 AnySetup *KmManagment\n" + "*KmManagment Default/Aus: \"\"\n" + "*KmManagment MG12345/Code 12345: \"\"\n" + "*?KmManagment: \"\"\n" + "*End\n" + "*CloseUI: *KmManagment\n\n" + "*CloseGroup: AccountingOptions/Job Accounting\n" +) + +result = re.sub( + r'\*OpenGroup: AccountingOptions.*?\*CloseGroup: AccountingOptions[^\n]*\n', + new_section, ppd, flags=re.DOTALL +) + +pathlib.Path("ppd/TA3505ci_AS.ppd").write_text(result, encoding="latin-1") +print("Done. Verify with: grep -n 'KmManagment' ppd/TA3505ci_AS.ppd | head -8") +PYEOF +``` + +Expected output: `Done. Verify with: ...` + +- [ ] **Step 4: Replace placeholder code with actual department code** + +Open `ppd/TA3505ci_AS.ppd` in a text editor. Find the two lines containing `MG12345` and replace `12345` with your real 5-digit department code. Example — if your code is `54321`: + +``` +*KmManagment MG54321/Code 54321: "" +``` + +- [ ] **Step 5: Validate PPD** + +```bash +cupstestppd ppd/TA3505ci_AS.ppd +``` + +Expected: the output ends with `PASS`. Warnings about missing options are acceptable; errors are not. + +- [ ] **Step 6: Commit** + +```bash +git add ppd/TA3505ci_AS.ppd +git commit -m "feat: add Apple Silicon PPD derived from Intel TA3505ci.PPD" +``` + +--- + +### Task 3: Filter — option parsing (TDD) + +**Files:** +- Create: `tests/conftest.py` +- Create: `tests/test_kyofilter.py` (partial — only TestParseOptions) +- Create: `filter/kyofilter` (partial — only `parse_options`) + +CUPS filters have no `.py` extension. `conftest.py` makes the file importable via `importlib`. + +- [ ] **Step 1: Create `tests/conftest.py`** + +```python +import importlib.util, pathlib, sys + +_path = pathlib.Path(__file__).parent.parent / "filter" / "kyofilter" +_spec = importlib.util.spec_from_file_location("kyofilter", _path) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +sys.modules["kyofilter"] = _mod +``` + +- [ ] **Step 2: Write failing tests for `parse_options`** + +Create `tests/test_kyofilter.py`: + +```python +import kyofilter + + +class TestParseOptions: + def test_single_key_value(self): + assert kyofilter.parse_options("KmManagment=MG12345") == {"KmManagment": "MG12345"} + + def test_multiple_options(self): + result = kyofilter.parse_options("KmManagment=MG12345 Duplex=DuplexNoTumble") + assert result == {"KmManagment": "MG12345", "Duplex": "DuplexNoTumble"} + + def test_empty_string(self): + assert kyofilter.parse_options("") == {} + + def test_flag_without_value(self): + assert kyofilter.parse_options("SomeFlag") == {"SomeFlag": "true"} +``` + +- [ ] **Step 3: Run tests — expect failure** + +```bash +pytest tests/test_kyofilter.py::TestParseOptions -v +``` + +Expected: `ERROR` — module `kyofilter` not found yet (file doesn't exist). + +- [ ] **Step 4: Create `filter/kyofilter` with `parse_options`** + +```python +#!/usr/bin/env python3 +"""CUPS filter for TA 3505ci - injects department account code into PostScript stream.""" +import sys + + +def parse_options(options_str): + options = {} + for token in options_str.split(): + if '=' in token: + k, v = token.split('=', 1) + options[k] = v + else: + options[token] = 'true' + return options + + +if __name__ == '__main__': + pass +``` + +```bash +chmod +x filter/kyofilter +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest tests/test_kyofilter.py::TestParseOptions -v +``` + +Expected: +``` +PASSED tests/test_kyofilter.py::TestParseOptions::test_single_key_value +PASSED tests/test_kyofilter.py::TestParseOptions::test_multiple_options +PASSED tests/test_kyofilter.py::TestParseOptions::test_empty_string +PASSED tests/test_kyofilter.py::TestParseOptions::test_flag_without_value +4 passed +``` + +- [ ] **Step 6: Commit** + +```bash +git add filter/kyofilter tests/conftest.py tests/test_kyofilter.py +git commit -m "feat: filter option parsing with tests" +``` + +--- + +### Task 4: Filter — account code extraction (TDD) + +**Files:** +- Modify: `tests/test_kyofilter.py` (add TestGetAccountCode) +- Modify: `filter/kyofilter` (add `get_account_code`) + +- [ ] **Step 1: Add failing tests for `get_account_code`** + +Append to `tests/test_kyofilter.py`: + +```python + +class TestGetAccountCode: + def test_five_digit_code_zero_padded_to_eight(self): + assert kyofilter.get_account_code({"KmManagment": "MG12345"}) == "00012345" + + def test_one_digit_code_zero_padded(self): + assert kyofilter.get_account_code({"KmManagment": "MG1"}) == "00000001" + + def test_eight_digit_code_unchanged(self): + assert kyofilter.get_account_code({"KmManagment": "MG12345678"}) == "12345678" + + def test_default_returns_none(self): + assert kyofilter.get_account_code({"KmManagment": "Default"}) is None + + def test_missing_key_returns_none(self): + assert kyofilter.get_account_code({}) is None + + def test_no_mg_prefix_returns_none(self): + assert kyofilter.get_account_code({"KmManagment": "12345"}) is None + + def test_non_numeric_code_returns_none(self): + assert kyofilter.get_account_code({"KmManagment": "MGabc45"}) is None +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest tests/test_kyofilter.py::TestGetAccountCode -v +``` + +Expected: `AttributeError: module 'kyofilter' has no attribute 'get_account_code'` + +- [ ] **Step 3: Add `get_account_code` to `filter/kyofilter`** + +Insert after `parse_options` (before `if __name__`): + +```python + +def get_account_code(options): + km = options.get('KmManagment', 'Default') + if km == 'Default' or not km.startswith('MG'): + return None + code = km[2:] + if not code.isdigit(): + return None + return code.zfill(8) +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest tests/test_kyofilter.py::TestGetAccountCode -v +``` + +Expected: `7 passed` + +- [ ] **Step 5: Commit** + +```bash +git add filter/kyofilter tests/test_kyofilter.py +git commit -m "feat: account code extraction with zero-padding" +``` + +--- + +### Task 5: Filter — stream processing (TDD) + +**Files:** +- Modify: `tests/test_kyofilter.py` (add TestProcessStream) +- Modify: `filter/kyofilter` (add `process_stream`) + +`process_stream` takes an iterable of `bytes` lines and yields `bytes` lines, injecting the account code command before `%%EndSetup` (or before `%%Page:` as fallback). + +- [ ] **Step 1: Add failing tests for `process_stream`** + +Append to `tests/test_kyofilter.py`: + +```python + +class TestProcessStream: + def _stream(self, text): + return iter(line.encode() for line in text.splitlines(keepends=True)) + + def _collect(self, text, code): + return b"".join(kyofilter.process_stream(self._stream(text), code)) + + def test_passthrough_when_no_code(self): + ps = "%!PS\n%%BeginSetup\n%%EndSetup\n%%Page: 1 1\n" + assert self._collect(ps, None) == ps.encode() + + def test_injects_before_end_setup(self): + ps = "%!PS\n%%BeginSetup\n%%EndSetup\n%%Page: 1 1\n" + result = self._collect(ps, "00012345") + assert b"(00012345) statusdict /setmanagementnumber get exec\n%%EndSetup\n" in result + + def test_end_setup_preserved_after_injection(self): + ps = "%!PS\n%%BeginSetup\n%%EndSetup\n" + result = self._collect(ps, "00012345") + assert b"%%EndSetup\n" in result + + def test_fallback_injects_before_first_page_when_no_end_setup(self): + ps = "%!PS\n%%Page: 1 1\nshowpage\n" + result = self._collect(ps, "00012345") + assert b"(00012345) statusdict /setmanagementnumber get exec\n%%Page: 1 1\n" in result + + def test_injects_only_once(self): + ps = "%!PS\n%%BeginSetup\n%%EndSetup\n%%Page: 1 1\n%%Page: 2 1\n" + result = self._collect(ps, "00012345") + assert result.count(b"setmanagementnumber") == 1 + + def test_content_before_injection_point_preserved(self): + ps = "%!PS\n%%BeginSetup\n/mydict 10 dict def\n%%EndSetup\n" + result = self._collect(ps, "00012345") + assert b"/mydict 10 dict def\n" in result + + def test_content_after_injection_point_preserved(self): + ps = "%!PS\n%%BeginSetup\n%%EndSetup\nshowpage\n" + result = self._collect(ps, "00012345") + assert b"showpage\n" in result +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest tests/test_kyofilter.py::TestProcessStream -v +``` + +Expected: `AttributeError: module 'kyofilter' has no attribute 'process_stream'` + +- [ ] **Step 3: Add `process_stream` to `filter/kyofilter`** + +Insert after `get_account_code` (before `if __name__`): + +```python + +def process_stream(lines, account_code): + if not account_code: + yield from lines + return + + inject = f"({account_code}) statusdict /setmanagementnumber get exec\n".encode() + injected = False + + for line in lines: + stripped = line.rstrip(b'\r\n') + if not injected and (stripped == b'%%EndSetup' or stripped.startswith(b'%%Page:')): + yield inject + injected = True + yield line + + if not injected: + sys.stderr.write("kyofilter: WARNING: no injection point found in PostScript stream\n") +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest tests/test_kyofilter.py::TestProcessStream -v +``` + +Expected: `7 passed` + +- [ ] **Step 5: Run all tests** + +```bash +pytest tests/ -v +``` + +Expected: `18 passed` + +- [ ] **Step 6: Commit** + +```bash +git add filter/kyofilter tests/test_kyofilter.py +git commit -m "feat: PostScript stream processing with account code injection" +``` + +--- + +### Task 6: Filter — `main()` and offline smoke test + +**Files:** +- Modify: `filter/kyofilter` (replace `if __name__ == '__main__': pass` with full `main()`) + +- [ ] **Step 1: Replace the `main()` stub** + +Replace the last three lines of `filter/kyofilter`: + +```python +# Remove: +if __name__ == '__main__': + pass +``` + +With: + +```python + +def main(): + if len(sys.argv) < 6: + sys.stderr.write("Usage: kyofilter job-id user title copies options [file]\n") + sys.exit(1) + + options = parse_options(sys.argv[5]) + account_code = get_account_code(options) + + infile = open(sys.argv[6], 'rb') if len(sys.argv) > 6 else sys.stdin.buffer + try: + for chunk in process_stream(infile, account_code): + sys.stdout.buffer.write(chunk) + finally: + if len(sys.argv) > 6: + infile.close() + + +if __name__ == '__main__': + main() +``` + +- [ ] **Step 2: Smoke test — with department code** + +```bash +printf '%%!PS\n%%%%BeginSetup\n%%%%EndSetup\n%%%%Page: 1 1\nshowpage\n' \ + | python3 filter/kyofilter 1 user "Test" 1 "KmManagment=MG12345" +``` + +Expected output contains (in this order): +``` +%%BeginSetup +(00012345) statusdict /setmanagementnumber get exec +%%EndSetup +``` + +- [ ] **Step 3: Smoke test — no department code (passthrough)** + +```bash +printf '%%!PS\n%%%%BeginSetup\n%%%%EndSetup\n' \ + | python3 filter/kyofilter 1 user "Test" 1 "" +``` + +Expected: output identical to input — no `setmanagementnumber` line. + +- [ ] **Step 4: Run all tests once more** + +```bash +pytest tests/ -v +``` + +Expected: `18 passed` + +- [ ] **Step 5: Commit** + +```bash +git add filter/kyofilter +git commit -m "feat: complete kyofilter with CUPS main() entrypoint" +``` + +--- + +### Task 7: Installer structure + +**Files:** +- Create: `installer/scripts/postinstall` +- Create: `installer/build.sh` + +`installer/root/` is generated by `build.sh` at build time and is git-ignored. + +- [ ] **Step 1: Create `installer/scripts/postinstall`** + +```bash +#!/bin/bash +set -e +chmod 755 /usr/libexec/cups/filter/kyofilter +chown root:wheel /usr/libexec/cups/filter/kyofilter +launchctl kickstart -k system/org.cups.cupsd +exit 0 +``` + +```bash +chmod +x installer/scripts/postinstall +``` + +- [ ] **Step 2: Create `installer/build.sh`** + +```bash +#!/bin/bash +set -e + +VERSION="1.0.0" +IDENTIFIER="de.kydriv.driver" +PKG_NAME="kydriv-driver-${VERSION}.pkg" + +# Build installer/root from source files +mkdir -p installer/root/Library/Printers/PPDs/Contents/Resources +mkdir -p installer/root/usr/libexec/cups/filter + +cp ppd/TA3505ci_AS.ppd \ + installer/root/Library/Printers/PPDs/Contents/Resources/ +cp filter/kyofilter \ + installer/root/usr/libexec/cups/filter/ +chmod 755 installer/root/usr/libexec/cups/filter/kyofilter + +# Build (unsigned if SIGN_IDENTITY not set) +SIGN_ARGS=() +if [ -n "${SIGN_IDENTITY}" ]; then + SIGN_ARGS=(--sign "${SIGN_IDENTITY}") +fi + +pkgbuild \ + --root installer/root \ + --scripts installer/scripts \ + --identifier "${IDENTIFIER}" \ + --version "${VERSION}" \ + "${SIGN_ARGS[@]}" \ + "${PKG_NAME}" + +echo "" +echo "Built: ${PKG_NAME}" +if [ -z "${SIGN_IDENTITY}" ]; then + echo "" + echo "To sign: SIGN_IDENTITY='Developer ID Installer: NAME (TEAMID)' bash installer/build.sh" + echo "" + echo "To notarize after signing:" + echo " xcrun notarytool submit ${PKG_NAME} --apple-id YOUR@EMAIL --team-id TEAMID --password APP-PW --wait" + echo " xcrun stapler staple ${PKG_NAME}" +fi +``` + +```bash +chmod +x installer/build.sh +``` + +- [ ] **Step 3: Build unsigned package to verify structure** + +```bash +bash installer/build.sh +``` + +Expected: +``` +kydriv-driver-1.0.0.pkg +Built: kydriv-driver-1.0.0.pkg +``` + +```bash +pkgutil --payload-files kydriv-driver-1.0.0.pkg +``` + +Expected output includes: +``` +./Library/Printers/PPDs/Contents/Resources/TA3505ci_AS.ppd +./usr/libexec/cups/filter/kyofilter +``` + +- [ ] **Step 4: Commit** + +```bash +git add installer/scripts/postinstall installer/build.sh +git commit -m "feat: pkg installer with postinstall and build script" +``` + +--- + +### Task 8: Local install and print test (VPN required) + +**Voraussetzung:** VPN-Verbindung zum Büronetz aktiv. Druckeradresse (IP oder Hostname) bekannt. + +- [ ] **Step 1: Validate PPD (offline)** + +```bash +cupstestppd ppd/TA3505ci_AS.ppd +``` + +Expected: ends with `PASS` + +- [ ] **Step 2: Install filter and PPD manually** + +```bash +sudo cp filter/kyofilter /usr/libexec/cups/filter/kyofilter +sudo chmod 755 /usr/libexec/cups/filter/kyofilter +sudo chown root:wheel /usr/libexec/cups/filter/kyofilter +sudo cp ppd/TA3505ci_AS.ppd \ + /Library/Printers/PPDs/Contents/Resources/TA3505ci_AS.ppd +sudo launchctl kickstart -k system/org.cups.cupsd +``` + +- [ ] **Step 3: Add printer in System Settings** + +Systemeinstellungen → Drucker & Scanner → `+` → Drucker per IP hinzufügen: +- Protokoll: Line Printer Daemon – LPD +- Adresse: IP/Hostname des 3505ci +- Treiber: „Software auswählen…" → `3505ci (KPDL)` (unsere PPD) wählen + +- [ ] **Step 4: Test print without department code** + +Beliebiges Dokument drucken, Job Accounting auf „Aus" lassen. Seite muss ankommen. + +- [ ] **Step 5: Test print with department code** + +Druckdialog → Job Accounting → Ihren Code wählen → drucken. + +Im Kyocera-Webinterface (http://\) unter „Auftragsstatus" oder „Auftragsverlauf" prüfen, dass die Kostenstelle korrekt erfasst wurde. + +- [ ] **Step 6: Verify injection via CUPS log** + +```bash +tail -50 /var/log/cups/error_log | grep -i "kyofilter\|account\|managment" +``` + +Keine Fehler oder Warnungen aus `kyofilter` sollten erscheinen. + +- [ ] **Step 7: Save preset** + +Druckdialog → Kostenstelle gewählt → Vorgaben → „Aktuelle Einstellungen als Vorgabe sichern…" → Name vergeben → `OK`. Preset beim nächsten Druckjob verwenden. + +--- + +### Task 9: Build and sign .pkg + +**Voraussetzung:** Developer ID Installer-Zertifikat im Keychain. Team-ID und App-spezifisches Passwort (appleid.apple.com) bereit. + +- [ ] **Step 1: Build signed package** + +```bash +SIGN_IDENTITY="Developer ID Installer: IHR NAME (TEAMID)" \ + bash installer/build.sh +``` + +Expected: `Built: kydriv-driver-1.0.0.pkg` (ohne unsigned-Warnung) + +- [ ] **Step 2: Verify signature** + +```bash +pkgutil --check-signature kydriv-driver-1.0.0.pkg +``` + +Expected: `Status: signed by a certificate trusted by macOS` + +- [ ] **Step 3: Notarize** + +```bash +xcrun notarytool submit kydriv-driver-1.0.0.pkg \ + --apple-id IHR@EMAIL \ + --team-id TEAMID \ + --password APP-SPEZIFISCHES-PASSWORT \ + --wait +``` + +Expected: `status: Accepted` + +- [ ] **Step 4: Staple notarization ticket** + +```bash +xcrun stapler staple kydriv-driver-1.0.0.pkg +``` + +Expected: `The staple and validate action worked!` + +- [ ] **Step 5: Test .pkg on clean setup** + +Filter und PPD deinstallieren, dann `.pkg` doppelklicken → installieren → Drucker neu einrichten → Preset testen. + +```bash +# Cleanup für sauberen Test: +sudo rm /usr/libexec/cups/filter/kyofilter +sudo rm /Library/Printers/PPDs/Contents/Resources/TA3505ci_AS.ppd +sudo launchctl kickstart -k system/org.cups.cupsd +``` + +- [ ] **Step 6: Commit and tag** + +```bash +git add -f kydriv-driver-1.0.0.pkg # optional: binary in repo +git commit -m "release: v1.0.0" +git tag v1.0.0 +``` + +Oder: `.pkg` separat aufbewahren (nicht im Repo), nur Source-Code committen.