Browse Source

fix(install): sign the Python that macOS grants local network access to (issue #3114)

    macOS attributes Local Network permission to a code signature and judges a
    launchd-spawned process on its own, rather than letting it inherit the grant
    of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
    there is no identity for the grant to attach to: every connection to a LAN
    address is dropped with no error the application can log and no permission
    prompt. The printer reads as unreachable and nothing says why, and the entry
    in Privacy & Security cannot be made to work because it refers to an identity
    that no longer resolves.

    install.sh signs during a macOS install; update_macos.sh re-checks on every
    update, because `brew upgrade python` installs a fresh unsigned binary under
    a new versioned path.

    Both sign only what is currently unsigned. That gate is load-bearing: on
    arm64 the linker ad-hoc signs every binary and the identity is a hash of the
    file, so re-signing would rotate it and revoke a working grant on each update.
    A python.org build carries a real Developer ID and must not be downgraded for
    the same reason.

    The interpreter and the framework's Python.app are both signed. The first is
    what sys._base_executable resolves to and what the reporter's TCC log names;
    the second is what his fix actually targeted. Which one macOS attributes
    could not be established from either, and signing both costs nothing.

    -----

    fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)

    The port checks reported all three ports unreachable while the subnet check
    passed, and port_mqtt's fix text sent the reporter after firewalls and IP
    addresses. On a macOS native install that pattern has a cause neither of
    those covers: no Local Network grant, denied with no error and no prompt.

    A new macos_local_network check, appended on macOS only so no permanently
    dimmed row appears for anyone else. It passes when the control port answered,
    which is proof the permission is in place and means the signature probe never
    runs on a healthy diagnostic. Otherwise it probes the interpreter: an
    unsigned one gets the repair that fixes it, a signed one gets System Settings
    — the arm64 case, where the identity is a hash of the binary, so a Python
    upgrade presents macOS with a new application and strands the old grant.

    Always warn, never fail, and only once port_mqtt has already failed, so this
    can never be why a green diagnostic turns red. A printer that is simply
    switched off produces the same all-ports-dead pattern, which is why the
    signature, not the pattern, is what earns the specific advice. An
    undeterminable signature is reported as the generic case rather than as
    unsigned: that advice rewrites a file in the user's Python installation and
    must not be offered on a guess.
maziggy 4 days ago
parent
commit
2d385cf978

+ 6 - 2
backend/app/schemas/printer.py

@@ -446,10 +446,14 @@ class PrinterStatus(BaseModel):
 class DiagnosticCheck(BaseModel):
     """One connection-diagnostic check result.
 
-    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
-    subnet, mqtt_auth, developer_mode); the frontend renders the localized
+    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps,
+    macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
+    developer_mode, printer_publishing); the frontend renders the localized
     title and fix text from id + status. ``params`` carries interpolation
     values (e.g. network mode, IP addresses) for that text.
+
+    Not every check is emitted on every run: ``macos_local_network`` appears
+    only on macOS, where it is the only platform it can say anything about.
     """
 
     id: str

+ 103 - 0
backend/app/services/printer_diagnostic.py

@@ -12,8 +12,11 @@ user-side setup errors clustered on exactly these causes.
 import asyncio
 import ipaddress
 import logging
+import os
 import socket
 import ssl
+import subprocess
+import sys
 from pathlib import Path
 
 from backend.app.models.printer import Printer
@@ -336,6 +339,73 @@ def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
     return printer_addr in network
 
 
+# macOS attributes Local Network permission (TCC) to a process's code
+# signature, and judges a launchd-spawned process on its own instead of
+# letting it inherit the grant of the Terminal that started it. Homebrew's
+# Python is unsigned on Intel, so there is no identity for a grant to attach
+# to: every connection to a LAN address is dropped, with no error the
+# application can log and no permission prompt. All three printer ports read
+# as unreachable while the subnet check passes (#3114).
+_CODESIGN = "/usr/bin/codesign"
+# Reading a local file's signature takes milliseconds, so this is a guard
+# rather than a budget -- and it is deliberately short. The support bundle
+# gives each printer 15s total (_PER_DIAGNOSTIC_TIMEOUT_SECONDS) and drops
+# the whole connection diagnostic on overrun, so a codesign that hangs (the
+# stub that offers to install the command line tools is the plausible way)
+# must not be able to cost the bundle the rest of its checks.
+_CODESIGN_TIMEOUT = 2.0
+
+
+def _base_interpreter_path() -> str:
+    """The interpreter macOS judges, as both the probe and the message see it.
+
+    ``sys._base_executable`` rather than ``sys.executable``: inside a venv the
+    latter is a symlink in the venv's own bin directory, and what macOS judges
+    is the real interpreter it resolves to. Resolved once, here, so the path
+    reported to the user is the same one whose signature was read.
+    """
+    return os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
+
+
+def _interpreter_is_signed() -> bool | None:
+    """Does the interpreter Bambuddy runs under carry a code signature?
+
+    None when it cannot be told: no usable ``codesign`` because the Xcode
+    command line tools are absent, or the probe failed some other way. That
+    is deliberately not folded into False. The advice for "no identity" names
+    a repair that rewrites a file inside the user's Python installation, and
+    offering that on a guess is worse than giving the generic answer.
+
+    On an Apple Silicon Homebrew install the interpreter resolves to the
+    framework's ``bin/pythonX.Y`` (measured, inside and outside a venv alike)
+    -- not the ``Python.app`` stub, which is a separate binary in the same
+    framework. The reporter's TCC log names the same ``bin/pythonX.Y`` on
+    Intel.
+    """
+    executable = _base_interpreter_path()
+    if not executable:
+        return None
+    try:
+        result = subprocess.run(
+            [_CODESIGN, "-d", executable],
+            capture_output=True,
+            text=True,
+            timeout=_CODESIGN_TIMEOUT,
+        )
+    except Exception:
+        # Fail soft, as everywhere else in this module: a diagnostic that
+        # raises is worse than one that declines to answer.
+        logger.debug("codesign probe failed", exc_info=True)
+        return None
+    if result.returncode == 0:
+        return True
+    # codesign writes this to stderr and exits non-zero. It is the one
+    # outcome that separates "no identity at all" from "the probe never ran".
+    if "not signed at all" in result.stderr:
+        return False
+    return None
+
+
 async def run_connection_diagnostic(
     ip_address: str,
     *,
@@ -381,6 +451,39 @@ async def run_connection_diagnostic(
         )
     )
 
+    # --- macOS Local Network permission ---
+    # Appended on macOS only. Everywhere else there is nothing to say, and a
+    # permanently dimmed "skipped" row would be noise for the users who make
+    # up nearly all of them.
+    #
+    # Both outcomes are reported as warn rather than fail, and only when the
+    # control port is already unreachable -- so this can never be the check
+    # that turns an otherwise healthy result red. A printer that is simply
+    # switched off produces the same all-ports-dead pattern, which is why the
+    # signature probe, not the pattern, is what earns the specific advice.
+    if sys.platform == "darwin":
+        if mqtt_ok:
+            # The control port answered, so LAN access demonstrably works.
+            checks.append(DiagnosticCheck(id="macos_local_network", status="pass"))
+        else:
+            signed = await asyncio.to_thread(_interpreter_is_signed)
+            if signed is False:
+                checks.append(
+                    DiagnosticCheck(
+                        id="macos_local_network",
+                        status="warn",
+                        params={"reason": "unsigned", "executable": _base_interpreter_path()},
+                    )
+                )
+            else:
+                # Signed, or undeterminable. An ad-hoc signature -- which is
+                # what every arm64 binary carries, because the linker adds one
+                # -- identifies itself by a hash of the binary, so a Python
+                # upgrade presents macOS with a new application and leaves the
+                # old grant behind. That is repairable in System Settings,
+                # unlike the unsigned case, so point there instead.
+                checks.append(DiagnosticCheck(id="macos_local_network", status="warn", params={"reason": "permission"}))
+
     # --- Container network mode ---
     # Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
     # its users were told "Not running in Docker", which reads as "you are on

+ 117 - 0
backend/tests/unit/services/test_printer_diagnostic.py

@@ -7,6 +7,7 @@ so a status flip is a user-facing regression — each one is asserted here.
 
 import ipaddress
 import ssl
+import subprocess
 import types
 from contextlib import ExitStack
 from unittest.mock import AsyncMock, MagicMock, patch
@@ -14,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 from backend.app.services.printer_diagnostic import (
     _check_ftps_tls,
     _host_source_ip,
+    _interpreter_is_signed,
     _same_subnet,
     run_connection_diagnostic,
 )
@@ -77,6 +79,7 @@ class _Env:
         *,
         ports=None,
         ftps="ok",
+        platform="linux",
         runtime="Docker",
         network_mode="host",
         host_ip="192.168.1.5",
@@ -90,6 +93,11 @@ class _Env:
         self.ports = ports or _port_probe()
         # What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
         self.ftps = ftps
+        # Pinned so the check list does not depend on the OS the suite runs
+        # on: the macos_local_network check is emitted on darwin only (#3114),
+        # and a test asserting the full set would otherwise pass on Linux CI
+        # and fail on a maintainer's Mac.
+        self.platform = platform
         # Container engine detect_container_runtime() reports, None for bare metal.
         self.runtime = runtime
         self.network_mode = network_mode
@@ -130,6 +138,7 @@ class _Env:
             client.report_messages_since_connect = self.report_messages_since_connect
             client.last_connect_error = self.connect_error
             manager.get_client.return_value = client
+        self._stack.enter_context(patch(f"{MOD}.sys.platform", self.platform))
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}._check_ftps_tls", new_callable=AsyncMock, return_value=self.ftps))
         self._stack.enter_context(patch(f"{MOD}.detect_container_runtime", return_value=self.runtime))
@@ -850,3 +859,111 @@ class TestFtpsTlsProbe:
         assert capped.minimum_version == ssl.TLSVersion.TLSv1_2
         assert uncapped.maximum_version != ssl.TLSVersion.TLSv1_2
         assert uncapped.minimum_version == ssl.TLSVersion.TLSv1_2
+
+
+def _signature_probe(signed):
+    """Patch ``_interpreter_is_signed`` to answer ``signed``.
+
+    The platform itself is pinned by ``_Env(platform="darwin")``, so that one
+    patch cannot be undone by the environment entered after it.
+    """
+    probe = MagicMock(return_value=signed)
+    return patch(f"{MOD}._interpreter_is_signed", probe), probe
+
+
+class TestMacosLocalNetworkCheck:
+    """The macOS Local Network (TCC) check (#3114).
+
+    macOS attributes the permission to a code signature. An unsigned
+    interpreter has no identity to anchor a grant to, so every connection to
+    the printer is dropped with no error and no prompt — the ports read as
+    unreachable and nothing says why.
+    """
+
+    async def test_absent_on_other_platforms(self):
+        """No dimmed "skipped" row for the users who are not on a Mac."""
+        with _Env(platform="linux", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert "macos_local_network" not in _statuses(result)
+
+    async def test_passes_when_the_control_port_answers(self):
+        """A reachable printer is proof the permission is in place."""
+        patcher, probe = _signature_probe(False)
+        with patcher, _Env(platform="darwin", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["macos_local_network"] == "pass"
+        # And the subprocess never runs on a healthy diagnostic.
+        probe.assert_not_called()
+
+    async def test_unsigned_interpreter_names_the_repair(self):
+        patcher, _probe = _signature_probe(False)
+        with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        check = _check(result, "macos_local_network")
+        assert check.status == "warn"
+        assert check.params["reason"] == "unsigned"
+        # The path is carried so the user can see which interpreter is meant.
+        assert check.params["executable"]
+
+    async def test_signed_interpreter_points_at_system_settings(self):
+        """arm64 always has an ad-hoc signature, so this is the common case.
+
+        Its identity is a hash of the binary, so a Python upgrade presents
+        macOS with a new application and strands the old grant. That is
+        repairable in System Settings, unlike an unsigned interpreter.
+        """
+        patcher, _probe = _signature_probe(True)
+        with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        check = _check(result, "macos_local_network")
+        assert check.status == "warn"
+        assert check.params["reason"] == "permission"
+
+    async def test_undeterminable_signature_is_not_reported_as_unsigned(self):
+        """No codesign, no answer — and the specific advice is withheld.
+
+        It names a repair that rewrites a file in the user's Python install,
+        which must not be offered on a guess.
+        """
+        patcher, _probe = _signature_probe(None)
+        with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _check(result, "macos_local_network").params["reason"] == "permission"
+
+    async def test_never_turns_a_healthy_result_red(self):
+        """Only ever warn, and only when the port check already failed.
+
+        So this check cannot be the reason a diagnostic stops being green.
+        """
+        patcher, _probe = _signature_probe(False)
+        with patcher, _Env(platform="darwin", state=_state(), report_messages_since_connect=42):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert result.overall == "ok"
+
+
+class TestInterpreterSignatureProbe:
+    """``codesign`` has three outcomes and they must stay distinguishable."""
+
+    def _run(self, **kwargs):
+        return patch(f"{MOD}.subprocess.run", **kwargs)
+
+    def test_zero_exit_means_signed(self):
+        with self._run(return_value=types.SimpleNamespace(returncode=0, stderr="")):
+            assert _interpreter_is_signed() is True
+
+    def test_not_signed_at_all_means_unsigned(self):
+        stderr = "/usr/local/.../python3.14: code object is not signed at all"
+        with self._run(return_value=types.SimpleNamespace(returncode=1, stderr=stderr)):
+            assert _interpreter_is_signed() is False
+
+    def test_other_failure_is_undeterminable(self):
+        """A bad path or a codesign that would not run is not evidence."""
+        with self._run(return_value=types.SimpleNamespace(returncode=1, stderr="No such file or directory")):
+            assert _interpreter_is_signed() is None
+
+    def test_probe_failure_never_raises(self):
+        """A diagnostic that 500s the page is worse than one that says nothing."""
+        with self._run(side_effect=OSError("boom")):
+            assert _interpreter_is_signed() is None
+        with self._run(side_effect=subprocess.TimeoutExpired(cmd="codesign", timeout=5.0)):
+            assert _interpreter_is_signed() is None

+ 45 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -245,6 +245,51 @@ describe('ConnectionDiagnosticModal', () => {
     spy.mockRestore();
   });
 
+  it('names the interpreter when macOS has no signature to grant against (#3114)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'problems',
+      checks: [
+        {
+          id: 'macos_local_network',
+          status: 'warn',
+          params: {
+            reason: 'unsigned',
+            executable: '/usr/local/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/bin/python3.14',
+          },
+        },
+      ],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/macOS Local Network permission/i)).toBeInTheDocument();
+    expect(screen.getByText(/has no code signature/i)).toBeInTheDocument();
+    // The path is what tells the user which of several Pythons is meant.
+    expect(screen.getByText(/Versions\/3\.14\/bin\/python3\.14/)).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('points a signed-but-blocked macOS install at System Settings (#3114)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'problems',
+      checks: [{ id: 'macos_local_network', status: 'warn', params: { reason: 'permission' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/Privacy & Security > Local Network/i)).toBeInTheDocument();
+    // The signing repair must not be offered to a machine that is already
+    // signed: re-signing it would revoke the grant it still has.
+    expect(screen.queryByText(/update_macos\.sh/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
   it('falls back to the generic skip text when no reason is present', async () => {
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
       ...PROBLEM_RESULT,

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -6988,6 +6988,12 @@ export default {
         pass: 'Erreichbar — der Kamerastream funktioniert.',
         warn: 'Port {{port}} ist nicht erreichbar. Die Live-Kameraansicht funktioniert nicht. Dies betrifft das Drucken nicht.',
       },
+      macos_local_network: {
+        title: 'macOS-Berechtigung „Lokales Netzwerk“',
+        pass: 'macOS erlaubt Bambuddy den Zugriff auf das lokale Netzwerk.',
+        warn_unsigned: 'Das Python, das Bambuddy ausführt, hat keine Code-Signatur. Damit hat macOS nichts, woran es die Berechtigung „Lokales Netzwerk“ binden könnte, und verwirft jede Verbindung zum Drucker stillschweigend — ohne Fehler und ohne Nachfrage. Führe das Bambuddy-Update-Skript (install/update_macos.sh) aus, das die Signatur setzt, und starte Bambuddy anschließend neu. Interpreter: {{executable}}',
+        warn_permission: 'Wenn der Drucker eingeschaltet und unter dieser Adresse erreichbar ist, öffne Systemeinstellungen > Datenschutz & Sicherheit > Lokales Netzwerk und stelle sicher, dass das Python von Bambuddy aktiviert ist. macOS verwirft lokale Verbindungen sonst stillschweigend, und ein Python-Update kann die alte Berechtigung zurücklassen.',
+      },
       network_mode: {
         title: 'Container-Netzwerkmodus',
         genericRuntime: 'einem Container',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -7039,6 +7039,12 @@ export default {
         pass: 'Reachable — the camera stream will work.',
         warn: 'Port {{port}} is unreachable. The live camera view will not work. This does not affect printing.',
       },
+      macos_local_network: {
+        title: 'macOS Local Network permission',
+        pass: 'macOS is allowing Bambuddy to reach the local network.',
+        warn_unsigned: 'The Python that runs Bambuddy has no code signature, so macOS has nothing to attach a Local Network permission to and silently drops every connection to the printer — no error and no prompt. Run the Bambuddy updater (install/update_macos.sh), which signs it, then restart Bambuddy. Interpreter: {{executable}}',
+        warn_permission: 'If the printer is powered on and at this address, open System Settings > Privacy & Security > Local Network and make sure the Python that runs Bambuddy is enabled. macOS drops local connections silently when it is not, and updating Python can leave the old permission behind.',
+      },
       network_mode: {
         title: 'Container network mode',
         genericRuntime: 'a container',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -6996,6 +6996,12 @@ export default {
         pass: 'Accesible — la transmisión de la cámara funcionará.',
         warn: 'El puerto {{port}} no es accesible. La vista de la cámara en directo no funcionará. Esto no afecta a la impresión.',
       },
+      macos_local_network: {
+        title: 'Permiso de red local de macOS',
+        pass: 'macOS permite que Bambuddy acceda a la red local.',
+        warn_unsigned: 'El Python que ejecuta Bambuddy no tiene firma de código, así que macOS no tiene a qué asociar el permiso de red local y descarta en silencio todas las conexiones con la impresora: sin error y sin solicitud. Ejecuta el actualizador de Bambuddy (install/update_macos.sh), que lo firma, y reinicia Bambuddy. Intérprete: {{executable}}',
+        warn_permission: 'Si la impresora está encendida y en esta dirección, abre Ajustes del Sistema > Privacidad y seguridad > Red local y comprueba que el Python de Bambuddy esté activado. Si no lo está, macOS descarta las conexiones locales en silencio, y actualizar Python puede dejar atrás el permiso anterior.',
+      },
       network_mode: {
         title: 'Modo de red del contenedor',
         genericRuntime: 'un contenedor',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -6978,6 +6978,12 @@ export default {
         pass: 'Accessible — le flux de la caméra fonctionnera.',
         warn: 'Le port {{port}} est inaccessible. La vue caméra en direct ne fonctionnera pas. Cela n\'affecte pas l\'impression.',
       },
+      macos_local_network: {
+        title: 'Autorisation « Réseau local » de macOS',
+        pass: 'macOS autorise Bambuddy à accéder au réseau local.',
+        warn_unsigned: 'Le Python qui exécute Bambuddy n’a pas de signature de code : macOS n’a donc rien à quoi rattacher l’autorisation « Réseau local » et rejette silencieusement toutes les connexions vers l’imprimante, sans erreur ni demande. Lancez le script de mise à jour de Bambuddy (install/update_macos.sh), qui le signe, puis redémarrez Bambuddy. Interpréteur : {{executable}}',
+        warn_permission: 'Si l’imprimante est allumée et joignable à cette adresse, ouvrez Réglages Système > Confidentialité et sécurité > Réseau local et vérifiez que le Python de Bambuddy est activé. Sinon macOS rejette les connexions locales en silence, et une mise à jour de Python peut laisser l’ancienne autorisation derrière elle.',
+      },
       network_mode: {
         title: 'Mode réseau du conteneur',
         genericRuntime: 'un conteneur',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -6977,6 +6977,12 @@ export default {
         pass: 'Raggiungibile — lo streaming della fotocamera funzionerà.',
         warn: 'La porta {{port}} non è raggiungibile. La visualizzazione live della fotocamera non funzionerà. Questo non influisce sulla stampa.',
       },
+      macos_local_network: {
+        title: 'Autorizzazione « Rete locale » di macOS',
+        pass: 'macOS consente a Bambuddy di raggiungere la rete locale.',
+        warn_unsigned: 'Il Python che esegue Bambuddy non ha una firma del codice, quindi macOS non ha nulla a cui associare l’autorizzazione « Rete locale » e scarta in silenzio ogni connessione alla stampante, senza errori e senza richiesta. Esegui lo script di aggiornamento di Bambuddy (install/update_macos.sh), che lo firma, poi riavvia Bambuddy. Interprete: {{executable}}',
+        warn_permission: 'Se la stampante è accesa e raggiungibile a questo indirizzo, apri Impostazioni di Sistema > Privacy e sicurezza > Rete locale e verifica che il Python di Bambuddy sia abilitato. In caso contrario macOS scarta le connessioni locali in silenzio, e un aggiornamento di Python può lasciare indietro la vecchia autorizzazione.',
+      },
       network_mode: {
         title: 'Modalità di rete del container',
         genericRuntime: 'un container',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -6989,6 +6989,12 @@ export default {
         pass: '到達可能 — カメラストリームは機能します。',
         warn: 'ポート{{port}}に到達できません。ライブカメラ表示は機能しません。これは印刷には影響しません。',
       },
+      macos_local_network: {
+        title: 'macOS のローカルネットワーク権限',
+        pass: 'macOS は Bambuddy のローカルネットワークへのアクセスを許可しています。',
+        warn_unsigned: 'Bambuddy を実行している Python にコード署名がないため、macOS はローカルネットワーク権限を結び付ける対象を持てず、プリンターへの接続をエラーも確認ダイアログもなく破棄します。署名を行う Bambuddy の更新スクリプト (install/update_macos.sh) を実行してから、Bambuddy を再起動してください。インタープリター: {{executable}}',
+        warn_permission: 'プリンターの電源が入っていてこのアドレスで到達できる場合は、システム設定 > プライバシーとセキュリティ > ローカルネットワーク を開き、Bambuddy の Python が有効になっているか確認してください。無効だと macOS はローカル接続を無言で破棄します。また Python を更新すると以前の許可が引き継がれないことがあります。',
+      },
       network_mode: {
         title: 'コンテナのネットワークモード',
         genericRuntime: 'コンテナ',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -7089,6 +7089,12 @@ export default {
         pass: '연결 가능 — 카메라 스트림이 작동합니다.',
         warn: '포트 {{port}}에 연결할 수 없습니다. 라이브 카메라 보기가 작동하지 않습니다. 인쇄에는 영향을 주지 않습니다.'
       },
+      macos_local_network: {
+        title: 'macOS 로컬 네트워크 권한',
+        pass: 'macOS가 Bambuddy의 로컬 네트워크 접근을 허용하고 있습니다.',
+        warn_unsigned: 'Bambuddy를 실행하는 Python에 코드 서명이 없어 macOS가 로컬 네트워크 권한을 연결할 대상을 찾지 못하고, 프린터로 향하는 모든 연결을 오류도 확인 창도 없이 조용히 차단합니다. 서명을 수행하는 Bambuddy 업데이트 스크립트(install/update_macos.sh)를 실행한 다음 Bambuddy를 재시작하십시오. 인터프리터: {{executable}}',
+        warn_permission: '프린터가 켜져 있고 이 주소로 연결할 수 있다면 시스템 설정 > 개인 정보 보호 및 보안 > 로컬 네트워크를 열어 Bambuddy의 Python이 활성화되어 있는지 확인하십시오. 활성화되어 있지 않으면 macOS는 로컬 연결을 조용히 차단하며, Python을 업데이트하면 이전 권한이 남지 않을 수 있습니다.',
+      },
       network_mode: {
         title: '컨테이너 네트워크 모드',
         genericRuntime: '컨테이너',

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -7039,6 +7039,12 @@ export default {
         pass: 'Bereikbaar — de camerastream werkt.',
         warn: 'Poort {{port}} is niet bereikbaar. De livecamera werkt niet. Dit heeft geen invloed op afdrukken.',
       },
+      macos_local_network: {
+        title: 'macOS-toegang tot lokaal netwerk',
+        pass: 'macOS staat Bambuddy toe het lokale netwerk te bereiken.',
+        warn_unsigned: 'De Python waarmee Bambuddy draait heeft geen codehandtekening, dus macOS heeft niets om de toegang tot het lokale netwerk aan te koppelen en laat elke verbinding met de printer stilletjes vallen — zonder fout en zonder vraag. Voer het updatescript van Bambuddy (install/update_macos.sh) uit, dat de handtekening plaatst, en start Bambuddy daarna opnieuw. Interpreter: {{executable}}',
+        warn_permission: 'Staat de printer aan en is hij op dit adres bereikbaar, open dan Systeeminstellingen > Privacy en beveiliging > Lokaal netwerk en controleer of de Python van Bambuddy is ingeschakeld. Zo niet, dan laat macOS lokale verbindingen stilletjes vallen, en een Python-update kan de oude toestemming achterlaten.',
+      },
       network_mode: {
         title: 'Netwerkmodus van de container',
         genericRuntime: 'een container',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6977,6 +6977,12 @@ export default {
         pass: 'Acessível — o streaming da câmera funcionará.',
         warn: 'A porta {{port}} está inacessível. A visualização ao vivo da câmera não funcionará. Isso não afeta a impressão.',
       },
+      macos_local_network: {
+        title: 'Permissão de rede local do macOS',
+        pass: 'O macOS está permitindo que o Bambuddy alcance a rede local.',
+        warn_unsigned: 'O Python que executa o Bambuddy não tem assinatura de código, então o macOS não tem a que vincular a permissão de rede local e descarta silenciosamente todas as conexões com a impressora — sem erro e sem solicitação. Execute o atualizador do Bambuddy (install/update_macos.sh), que faz a assinatura, e reinicie o Bambuddy. Interpretador: {{executable}}',
+        warn_permission: 'Se a impressora estiver ligada e acessível neste endereço, abra Ajustes do Sistema > Privacidade e Segurança > Rede Local e verifique se o Python do Bambuddy está ativado. Caso contrário, o macOS descarta conexões locais em silêncio, e atualizar o Python pode deixar a permissão antiga para trás.',
+      },
       network_mode: {
         title: 'Modo de rede do contêiner',
         genericRuntime: 'um contêiner',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -6617,6 +6617,12 @@ export default {
         pass: "Доступен — видеопоток камеры будет работать.",
         warn: "Порт {{port}} недоступен. Просмотр камеры в реальном времени работать не будет. На печать это не влияет.",
       },
+      macos_local_network: {
+        title: 'Разрешение macOS на доступ к локальной сети',
+        pass: 'macOS разрешает Bambuddy доступ к локальной сети.',
+        warn_unsigned: 'У Python, под которым работает Bambuddy, нет подписи кода, поэтому macOS не к чему привязать разрешение на доступ к локальной сети и молча отбрасывает все подключения к принтеру — без ошибки и без запроса. Запустите сценарий обновления Bambuddy (install/update_macos.sh), который подписывает его, и перезапустите Bambuddy. Интерпретатор: {{executable}}',
+        warn_permission: 'Если принтер включён и доступен по этому адресу, откройте Настройки системы > Конфиденциальность и безопасность > Локальная сеть и убедитесь, что Python, под которым работает Bambuddy, включён. Иначе macOS молча отбрасывает локальные подключения, а обновление Python может оставить старое разрешение позади.',
+      },
       network_mode: {
         title: "Сетевой режим контейнера",
         genericRuntime: "контейнере",

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -6927,6 +6927,12 @@ export default {
         pass: 'Erişilebilir — kamera akışı çalışacak.',
         warn: 'Port {{port}} erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez.',
       },
+      macos_local_network: {
+        title: 'macOS Yerel Ağ izni',
+        pass: 'macOS, Bambuddy’nin yerel ağa erişmesine izin veriyor.',
+        warn_unsigned: 'Bambuddy’yi çalıştıran Python’un kod imzası yok; bu yüzden macOS Yerel Ağ iznini bağlayacak bir kimlik bulamıyor ve yazıcıya giden tüm bağlantıları hata vermeden, sormadan sessizce düşürüyor. İmzalamayı yapan Bambuddy güncelleme betiğini (install/update_macos.sh) çalıştırın, ardından Bambuddy’yi yeniden başlatın. Yorumlayıcı: {{executable}}',
+        warn_permission: 'Yazıcı açıksa ve bu adresten erişilebiliyorsa Sistem Ayarları > Gizlilik ve Güvenlik > Yerel Ağ bölümünü açıp Bambuddy’nin Python’unun etkin olduğundan emin olun. Etkin değilse macOS yerel bağlantıları sessizce düşürür; ayrıca Python güncellemesi eski izni geride bırakabilir.',
+      },
       network_mode: {
         title: 'Konteyner ağ modu',
         genericRuntime: 'bir konteyner',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -7031,6 +7031,12 @@ export default {
         pass: "Доступно — потік камери працюватиме.",
         warn: "Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.",
       },
+      macos_local_network: {
+        title: 'Дозвіл macOS на доступ до локальної мережі',
+        pass: 'macOS дозволяє Bambuddy доступ до локальної мережі.',
+        warn_unsigned: 'Python, під яким працює Bambuddy, не має підпису коду, тож macOS нема до чого прив’язати дозвіл на локальну мережу і мовчки відкидає всі з’єднання з принтером — без помилки та без запиту. Запустіть сценарій оновлення Bambuddy (install/update_macos.sh), який його підписує, і перезапустіть Bambuddy. Інтерпретатор: {{executable}}',
+        warn_permission: 'Якщо принтер увімкнений і доступний за цією адресою, відкрийте Системні параметри > Конфіденційність і безпека > Локальна мережа та переконайтеся, що Python від Bambuddy увімкнено. Інакше macOS мовчки відкидає локальні з’єднання, а оновлення Python може залишити старий дозвіл позаду.',
+      },
       network_mode: {
         title: "Мережевий режим контейнера",
         genericRuntime: "контейнері",

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6975,6 +6975,12 @@ export default {
         pass: '可达 — 摄像头视频流将正常工作。',
         warn: '端口 {{port}} 不可达。实时摄像头视图将无法工作。这不影响打印。',
       },
+      macos_local_network: {
+        title: 'macOS 本地网络权限',
+        pass: 'macOS 已允许 Bambuddy 访问本地网络。',
+        warn_unsigned: '运行 Bambuddy 的 Python 没有代码签名,macOS 因此无法将本地网络权限绑定到任何身份,会静默丢弃所有到打印机的连接——既没有错误,也不会弹出授权提示。请运行会完成签名的 Bambuddy 更新脚本 (install/update_macos.sh),然后重启 Bambuddy。解释器:{{executable}}',
+        warn_permission: '如果打印机已开机并可通过该地址访问,请打开 系统设置 > 隐私与安全性 > 本地网络,确认 Bambuddy 使用的 Python 已启用。未启用时 macOS 会静默丢弃本地连接,而且升级 Python 可能不会保留原有授权。',
+      },
       network_mode: {
         title: '容器网络模式',
         genericRuntime: '容器',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6975,6 +6975,12 @@ export default {
         pass: '可達 — 攝影機串流將正常運作。',
         warn: '連接埠 {{port}} 無法連線。即時攝影機檢視將無法運作。這不影響列印。',
       },
+      macos_local_network: {
+        title: 'macOS 本地網路權限',
+        pass: 'macOS 已允許 Bambuddy 存取本地網路。',
+        warn_unsigned: '執行 Bambuddy 的 Python 沒有程式碼簽章,macOS 因此無法將本地網路權限繫結到任何身分,會靜默丟棄所有連往印表機的連線——既沒有錯誤,也不會出現授權提示。請執行會完成簽章的 Bambuddy 更新指令碼 (install/update_macos.sh),然後重新啟動 Bambuddy。直譯器:{{executable}}',
+        warn_permission: '若印表機已開機且可透過此位址連線,請開啟 系統設定 > 隱私權與安全性 > 本地網路,確認 Bambuddy 使用的 Python 已啟用。未啟用時 macOS 會靜默丟棄本地連線,而且更新 Python 可能不會保留原有授權。',
+      },
       network_mode: {
         title: '容器網路模式',
         genericRuntime: '容器',

+ 75 - 0
install/install.sh

@@ -423,6 +423,80 @@ setup_virtualenv() {
     log_success "Virtual environment configured"
 }
 
+# macOS attributes Local Network permission (TCC) to a process's code
+# signature, and judges a launchd-spawned process on its own rather than
+# letting it inherit the grant of the Terminal that started it. Homebrew's
+# Python is unsigned on Intel, so there is no identity for a grant to attach
+# to: every connection to a LAN address is dropped with no error the app can
+# log and no permission prompt, and the printer just reads as unreachable
+# (#3114).
+#
+# Signing only when currently unsigned is load-bearing, not tidiness. On
+# arm64 the linker ad-hoc signs every binary it produces, so the identity is
+# a hash of the file itself; re-signing rotates that hash, invalidates a
+# working grant, and causes the very outage this repairs -- on every update.
+# A python.org build carries a real Developer ID for the same reason it must
+# not be touched.
+#
+# Both the interpreter and the framework's Python.app are signed. The first
+# is what sys._base_executable resolves to (measured on an Apple Silicon
+# Homebrew install, inside and outside a venv, and named as the responsible
+# process in the reporter's own TCC log on Intel); the second is the separate
+# binary whose signature is what actually fixed his machine. Which of the two
+# macOS attributes could not be established from either, and signing both
+# costs nothing.
+sign_python_for_tcc() {
+    [[ "$OS_TYPE" == "macos" ]] || return 0
+
+    local python_bin base_exe framework target signed_any=0
+    local -a targets=()
+
+    python_bin="$INSTALL_PATH/venv/bin/python3"
+    if [[ ! -x "$python_bin" ]]; then
+        return 0
+    fi
+
+    if ! command -v codesign &>/dev/null; then
+        log_warn "codesign not found — skipping the macOS Local Network signing step."
+        log_info "If the printer turns out to be unreachable, install the Xcode command line"
+        log_info "tools with 'xcode-select --install' and re-run install/update_macos.sh."
+        return 0
+    fi
+
+    log_info "Checking the Python code signature (macOS Local Network permission)..."
+
+    base_exe="$("$python_bin" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable))' 2>/dev/null)" || return 0
+    if [[ -z "$base_exe" ]] || [[ ! -e "$base_exe" ]]; then
+        return 0
+    fi
+    targets+=("$base_exe")
+
+    # .../Versions/3.13/bin/python3.13 -> .../Versions/3.13/Resources/Python.app
+    framework="${base_exe%/bin/*}"
+    if [[ "$framework" != "$base_exe" ]] && [[ -d "$framework/Resources/Python.app" ]]; then
+        targets+=("$framework/Resources/Python.app")
+    fi
+
+    for target in "${targets[@]}"; do
+        if codesign -dv "$target" &>/dev/null; then
+            continue
+        fi
+        if codesign --force --sign - "$target" &>/dev/null; then
+            log_success "Ad-hoc signed $target"
+            signed_any=1
+        else
+            log_warn "Could not sign $target"
+            log_info "Bambuddy may be unable to reach the printer. Run this by hand:"
+            log_info "  codesign --force --sign - \"$target\""
+        fi
+    done
+
+    if [[ "$signed_any" -eq 0 ]]; then
+        log_success "Python already carries a code signature"
+    fi
+    return 0
+}
+
 check_node_version() {
     # Returns 0 if Node.js 20+ is available, 1 otherwise
     if ! command -v node &>/dev/null; then
@@ -992,6 +1066,7 @@ main() {
 
     download_bambuddy
     setup_virtualenv
+    sign_python_for_tcc
     build_frontend
     create_directories
     create_env_file

+ 59 - 0
install/update_macos.sh

@@ -105,6 +105,64 @@ repair_loop_flag() {
   log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
 }
 
+# Re-apply the ad-hoc Python signature macOS needs to grant Local Network
+# access (#3114).
+#
+# The macOS twin of sign_python_for_tcc in install.sh, and here for two
+# reasons rather than one. An install created before that step existed has an
+# unsigned interpreter and no other way to acquire one -- the same gap
+# repair_loop_flag covers above. And it recurs: `brew upgrade python` installs
+# a fresh unsigned binary under a new versioned path, so this has to be
+# checked on every update, not once at install time.
+#
+# Without it, on an Intel Mac, TCC has no identity to anchor the grant to,
+# drops every connection to the printer with no error and no prompt, and the
+# entry in Privacy & Security cannot be made to work: the printer is simply
+# unreachable and nothing in the log says why.
+#
+# Only signs what is unsigned. On arm64 every binary already carries an
+# ad-hoc signature whose identity is a hash of the file, so re-signing would
+# rotate it and revoke a working grant on every single update.
+repair_python_signature() {
+  local python_bin base_exe framework target signed_any=0
+  local -a targets=()
+
+  python_bin="$INSTALL_DIR/venv/bin/python3"
+  [ -x "$python_bin" ] || return 0
+
+  if ! command -v codesign >/dev/null 2>&1; then
+    warn "codesign not found; skipping the macOS Local Network signing check."
+    warn "If the printer is unreachable, run 'xcode-select --install' and re-run this script."
+    return 0
+  fi
+
+  base_exe="$("$python_bin" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable))' 2>/dev/null)" || return 0
+  { [ -n "$base_exe" ] && [ -e "$base_exe" ]; } || return 0
+  targets+=("$base_exe")
+
+  # .../Versions/3.13/bin/python3.13 -> .../Versions/3.13/Resources/Python.app
+  framework="${base_exe%/bin/*}"
+  if [ "$framework" != "$base_exe" ] && [ -d "$framework/Resources/Python.app" ]; then
+    targets+=("$framework/Resources/Python.app")
+  fi
+
+  for target in "${targets[@]}"; do
+    if codesign -dv "$target" >/dev/null 2>&1; then
+      continue
+    fi
+    if codesign --force --sign - "$target" >/dev/null 2>&1; then
+      log "Ad-hoc signed $target so macOS can grant Local Network access (#3114)"
+      signed_any=1
+    else
+      warn "Could not sign $target; Bambuddy may be unable to reach the printer."
+      warn "Run by hand: codesign --force --sign - \"$target\""
+    fi
+  done
+
+  [ "$signed_any" -eq 0 ] || log "Restart any open Bambuddy page after this update; the signature changes only take effect on the restart below."
+  return 0
+}
+
 on_error() {
   local exit_code="$1"
 
@@ -260,6 +318,7 @@ else
 fi
 
 repair_loop_flag
+repair_python_signature
 
 log "Starting service: $SERVICE_NAME"
 launchctl load "$PLIST_PATH"

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DNh2afIf.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DIyt0owc.js"></script>
+    <script type="module" crossorigin src="/assets/index-DNh2afIf.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff