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

20 KiB
Raw Permalink Blame History

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

mkdir -p ppd filter tests installer/scripts
  • Step 2: Create .gitignore
__pycache__/
.pytest_cache/
*.pkg
installer/root/
  • Step 3: Install pytest
pip3 install pytest

Expected: Successfully installed pytest-... (or Requirement already satisfied)

  • Step 4: Commit
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
cp /Users/oliver/Downloads/TA3505ci.PPD ppd/TA3505ci_AS.ppd
  • Step 2: Remove Intel-only lines and update filter reference
# 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:

grep -c "APDialogExtension\|cupsPreFilter\|kyofilter_E" ppd/TA3505ci_AS.ppd

Expected: 0

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):

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
cupstestppd ppd/TA3505ci_AS.ppd

Expected: the output ends with PASS. Warnings about missing options are acceptable; errors are not.

  • Step 6: Commit
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
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:

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
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
#!/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
chmod +x filter/kyofilter
  • Step 5: Run tests — expect pass
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
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:


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
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__):


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
pytest tests/test_kyofilter.py::TestGetAccountCode -v

Expected: 7 passed

  • Step 5: Commit
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:


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
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__):


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
pytest tests/test_kyofilter.py::TestProcessStream -v

Expected: 7 passed

  • Step 5: Run all tests
pytest tests/ -v

Expected: 18 passed

  • Step 6: Commit
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:

# Remove:
if __name__ == '__main__':
    pass

With:


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
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)
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
pytest tests/ -v

Expected: 18 passed

  • Step 5: Commit
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
#!/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
chmod +x installer/scripts/postinstall
  • Step 2: Create installer/build.sh
#!/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
chmod +x installer/build.sh
  • Step 3: Build unsigned package to verify structure
bash installer/build.sh

Expected:

kydriv-driver-1.0.0.pkg
Built: kydriv-driver-1.0.0.pkg
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
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)
cupstestppd ppd/TA3505ci_AS.ppd

Expected: ends with PASS

  • Step 2: Install filter and PPD manually
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
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
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
pkgutil --check-signature kydriv-driver-1.0.0.pkg

Expected: Status: signed by a certificate trusted by macOS

  • Step 3: Notarize
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
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.

# 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
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.