kydriv/docs/superpowers/plans/2026-06-02-kydriv-implementation.md
2026-06-02 10:45:08 +02:00

760 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 030. 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://\<Drucker-IP\>) 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.