#!/bin/sh
# Copyright (c) 2026 Mentor GmbH, Paderborn (https://mentor-group.de) – PDFMy, https://pdfmy.de
# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
# pdfmy Scan für Linux – Einrichtung. Im Terminal: sh pdfmy-scan-linux.sh (oder: curl -fsSL https://pdfmy.de/download/pdfmy-scan-linux.sh | sh)
# Richtet ein: den Scan-Dienst (systemd-Benutzerdienst, startet mit der Anmeldung), den Eintrag „PDFMy“ im Anwendungsmenü
# (öffnet PDFs per Doppelklick in PDFMy) und auf Wunsch PDFMy als Standard für PDF-Dateien.
# Kein sudo nötig, alles nur für den angemeldeten Benutzer. Der Dienst ist nur vom eigenen Rechner aus erreichbar (127.0.0.1).
set -u
DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
CONF="${XDG_CONFIG_HOME:-$HOME/.config}"
DIR="$DATA/pdfmy-scan"
UNIT="$CONF/systemd/user/pdfmy-scan.service"
APPS="$DATA/applications"
echo ""
echo " pdfmy Scan für Linux 1.7.0 wird eingerichtet …"
echo ""
if [ "$(id -u)" = "0" ]; then echo " ✗ Bitte ohne sudo starten – pdfmy Scan wird nur für Ihren Benutzer eingerichtet."; exit 1; fi
PERL="$(command -v perl || true)"
if [ -z "$PERL" ] || ! command -v curl >/dev/null 2>&1; then
echo " ✗ Es fehlen Perl oder curl. Bitte installieren und erneut starten, z. B.:"
echo " Debian/Ubuntu/Mint: sudo apt install perl curl"
echo " Fedora: sudo dnf install perl curl"
echo " openSUSE: sudo zypper install perl curl"
echo " Arch: sudo pacman -S perl curl"
exit 1
fi
if ! "$PERL" -MJSON::PP -MIO::Socket::INET -MIO::Select -MMIME::Base64 -MFile::Temp -MEncode -e1 >/dev/null 2>&1; then
echo " ✗ Perl ist unvollständig (Module wie JSON::PP fehlen). Bitte nachinstallieren, z. B.:"
echo " Fedora: sudo dnf install perl-JSON-PP perl-IO perl-MIME-Base64 perl-Encode"
echo " andere: sudo apt install perl bzw. sudo zypper install perl"
exit 1
fi
mkdir -p "$DIR" "$APPS" "$CONF/systemd/user"
# 1. Scan-Dienst
cat > "$DIR/pdfmy-scan.pl" <<'PDFMY_PERL_END'
#!/usr/bin/perl
# Copyright (c) 2026 Mentor GmbH, Paderborn (https://mentor-group.de) – PDFMy, https://pdfmy.de
# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
# pdfmy Scan für Mac und Linux – macht Scanner für PDFMy (pdfmy.de) im Browser verfügbar.
# Spricht dieselbe Schnittstelle wie pdfmy Scan für Windows (http://127.0.0.1:52880), nur vom eigenen Rechner und nur für den eigenen Benutzer
# erreichbar. Ist 52880 von einem anderen Benutzer belegt (mehrere Konten gleichzeitig angemeldet), nimmt es den nächsten freien Port bis 52889.
# Protokolle: eSCL/AirScan (fast alle Geräte ab ca. 2015) und WSD-Scan (z. B. Dell Color MFP E525w, viele ältere Geräte) – ohne Treiber.
# Linux zusätzlich: SANE (scanimage) für USB- und ältere Geräte, sofern installiert.
# Nutzt nur Bordmittel: Perl, curl, dns-sd (Mac) bzw. avahi-browse (Linux). Scans gehen direkt an PDFMy im Browser – nichts ins Internet.
use File::Copy (); use Unicode::Normalize ();
use strict; use warnings; use utf8; use Encode qw(decode);
use IO::Socket::INET; use IO::Select; use Socket qw(sockaddr_in inet_aton AF_INET6 SOCK_DGRAM getaddrinfo unpack_sockaddr_in6); use JSON::PP; use MIME::Base64;
use POSIX qw(strftime :sys_wait_h); use Time::HiRes qw(time); use File::Path qw(make_path remove_tree); use File::Temp qw(tempfile);
our $VERSION = '1.7.0';
my $IS_MAC = $^O eq 'darwin';
my $OS = $IS_MAC ? 'Mac' : 'Linux';
my $PORT = $ENV{PDFMY_PORT} || 52880; # tatsächlich belegter Port steht nach dem Start in $DIR/port
my $WSD_PORT = $ENV{PDFMY_WSD_PORT} || 3702;
my $DIR = $ENV{PDFMY_DIR} || ($IS_MAC ? "$ENV{HOME}/Library/Application Support/pdfmy-scan" : ($ENV{XDG_DATA_HOME} || "$ENV{HOME}/.local/share") . '/pdfmy-scan');
# Entwicklungsadressen nur mit PDFMY_DEV=1 – sonst gälte jeder fremde lokale Server auf Port 8765/8766 als PDFMy
my @ALLOWED = ('https://pdfmy.de', 'https://www.pdfmy.de', $ENV{PDFMY_DEV} ? ('http://localhost:8765', 'http://localhost:8766', 'http://127.0.0.1:8765', 'http://127.0.0.1:8766') : ());
my $J = JSON::PP->new->utf8->canonical;
make_path("$DIR/jobs");
$SIG{CHLD} = 'IGNORE'; $SIG{PIPE} = 'IGNORE';
# ---------------- Hilfen ----------------
sub logl { my $m = shift; $m =~ s/\s+/ /g; if (open my $f, '>>:utf8', "$DIR/log.txt") { print $f strftime('%Y-%m-%d %H:%M:%S ', localtime) . "$m\n"; close $f; } }
sub slurp { my $f = shift; open my $h, '<:raw', $f or return undef; local $/; my $d = <$h>; close $h; return $d; }
sub spit { my ($f, $d) = @_; open my $h, '>:raw', "$f.tmp" or return; print $h $d; close $h; rename "$f.tmp", $f; }
sub trim { my $s = shift // ''; $s =~ s/^\s+|\s+$//g; return $s; }
sub esc { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</g; $s =~ s/>/>/g; return $s; }
sub tagv { my ($x, $t) = @_; return $x =~ /<(?:[\w-]+:)?$t\b[^>]*>\s*([^<]*?)\s* 399 ? $#z - 399 : 0) .. $#z]) . "\n") if @z > 400; }
# HTTP-Client über curl (auf jedem Mac vorhanden). Gibt (Statuscode, Kopfzeilen, Inhalt) zurück.
sub http {
my %o = @_;
my ($bf, $bn) = tempfile(DIR => $DIR); close $bf;
my ($hf, $hn) = tempfile(DIR => $DIR); close $hf;
my @a = ('curl', '-s', '-m', ($o{timeout} || 30), '--connect-timeout', ($o{connect} || 5), '-o', $bn, '-D', $hn, '-w', '%{http_code}', '-H', 'Expect:', '-X', ($o{method} || 'GET'));
push @a, '-H', "Content-Type: $o{ctype}" if $o{ctype};
push @a, '-k' if $o{url} =~ m{^https://}; # Scanner im eigenen Netz haben selbstsignierte Zertifikate
push @a, '-H', "Host: $o{hosthdr}" if $o{hosthdr};
if (!$o{hosthdr} && $o{porthost} && $o{url} =~ m{^(https?)://(\[[^\]]+\]|[^/:]+)(?::(\d+))?}) { push @a, '-H', "Host: $2:" . ($3 // ($1 eq 'https' ? 443 : 80)); } # Epson: Port im Host-Kopf
my $qn;
if (defined $o{body}) { my $qf; ($qf, $qn) = tempfile(DIR => $DIR); binmode $qf; print $qf $o{body}; close $qf; push @a, '--data-binary', "\@$qn"; }
push @a, $o{url};
my $code = 0;
if (open my $p, '-|', @a) { local $/; $code = <$p> // 0; close $p; }
my $body = slurp($bn) // ''; my $hdr = slurp($hn) // '';
unlink grep { defined } $bn, $hn, $qn;
$code =~ s/\D//g;
return (($code || 0) + 0, $hdr, $body);
}
sub hdr { my ($h, $name) = @_; my @m = $h =~ /^\Q$name\E:[ \t]*([^\r\n]*)/mig; return $m[-1]; }
# Befehl mit Zeitlimit ausführen und die Ausgabe sammeln (für dns-sd, das sonst endlos läuft)
sub run_for {
my ($secs, @cmd) = @_;
my $pid = open(my $fh, '-|', @cmd) or return '';
my $sel = IO::Select->new($fh); my $out = ''; my $end = time + $secs;
while ((my $left = $end - time) > 0) { last unless $sel->can_read($left); my $n = sysread($fh, my $buf, 65536); last unless $n; $out .= $buf; }
kill 'TERM', $pid; close $fh;
return $out;
}
# Eigene IPv4-Adressen aller aktiven Netzwerkkarten (WLAN, LAN, VPN …) – für die Suche über jede Karte
sub local_ipv4 {
my $o = run_for(2, $IS_MAC ? ('/sbin/ifconfig') : (which('ip') ? (which('ip'), '-4', '-o', 'addr', 'show') : ('hostname', '-I')));
my @a = $o =~ /\binet\s/ ? ($o =~ /\binet\s+(?:addr:)?(\d{1,3}(?:\.\d{1,3}){3})/g) : ($o =~ /(\d{1,3}(?:\.\d{1,3}){3})/g); # ifconfig/ip: nur die Adresse nach „inet“
my %ip = map { $_ => 1 } grep { !/^127\./ && !/^169\.254\./ } @a;
return sort keys %ip;
}
# Netzsuche: welche Adressen im eigenen Netz (privat, /24) haben Port 80 oder 443 offen? Gleichzeitig, in Gruppen (macOS erlaubt nur 256 offene Dateien)
sub subnet_hosts {
my %t;
for my $lip (local_ipv4()) {
my @b = split /\./, $lip;
next unless $b[0] == 10 || ($b[0] == 192 && $b[1] == 168) || ($b[0] == 172 && $b[1] >= 16 && $b[1] <= 31);
$t{"$b[0].$b[1].$b[2].$_"} = 1 for grep { "$b[0].$b[1].$b[2].$_" ne $lip } 1 .. 254;
}
my @targets = map { my $ip = $_; map { [$ip, $_] } 80, 443, 631, 5357, 9100 } sort keys %t; my %open; # Web und Druckerports (IPP, WSD, RAW)
while (my @batch = splice @targets, 0, 150) {
my %pending;
for my $tp (@batch) { my $s = IO::Socket::INET->new(PeerAddr => $tp->[0], PeerPort => $tp->[1], Proto => 'tcp', Blocking => 0) or next; $pending{fileno $s} = [$s, @$tp]; }
my $sel = IO::Select->new(map { $_->[0] } values %pending); my $end = time + 1.2;
while ($sel->count && (my $left = $end - time) > 0) {
for my $s ($sel->can_write($left)) {
my $e = getsockopt($s, Socket::SOL_SOCKET(), Socket::SO_ERROR()); my ($ip, $port) = @{ $pending{fileno $s} }[1, 2];
$open{$ip}{$port} = 1 if $e && unpack('i', $e) == 0 && $s->connected;
$sel->remove($s); close $s;
}
}
close $_->[0] for values %pending;
}
return \%open; # IP → { 80 => 1, 443 => 1, 631 => 1, … }
}
# ---------------- Geräteliste (Datei, damit alle Prozesse dieselbe sehen) ----------------
sub devices { my $d = slurp("$DIR/devices.json"); my $l = $d ? eval { $J->decode($d) } : undef; return ref $l eq 'ARRAY' ? $l : []; }
sub save_devices { spit("$DIR/devices.json", $J->encode($_[0])); }
sub added_ips { my $d = slurp("$DIR/added.json"); my $l = $d ? eval { $J->decode($d) } : undef; return ref $l eq 'ARRAY' ? $l : []; }
sub find_dev { my $id = shift; for (@{ devices() }) { return $_ if $_->{id} eq $id; } return undef; }
# ---------------- eSCL / AirScan ----------------
sub escl_probe {
my $base = shift; $base =~ s{/+$}{};
my ($c, $h, $x) = http(url => "$base/ScannerCapabilities", timeout => 5, connect => 3);
return undef unless $c == 200 && $x =~ /<(?:[\w-]+:)?ScannerCapabilities\b/;
$x = decode('UTF-8', $x);
my %d = (kind => 'escl', base => $base, id => "escl:$base");
$d{name} = trim(tagv($x, 'MakeAndModel')) || 'Scanner ' . host_of($base);
$d{feeder} = $x =~ /<(?:[\w-]+:)?Adf\b/ ? 1 : 0;
$d{duplex} = $x =~ /AdfDuplexInputCaps/ ? 1 : 0;
$d{platen} = $x =~ /<(?:[\w-]+:)?Platen\b/ ? 1 : 0;
$d{platen} = 1 unless $d{feeder} || $d{platen};
$d{jpeg} = $x =~ m{image/jpeg} ? 1 : 0; $d{pdf} = $x =~ m{application/pdf} ? 1 : 0;
$d{ver} = trim(tagv($x, 'Version')) || '2.0';
my %r; $r{$1} = 1 while $x =~ /<(?:[\w-]+:)?DiscreteResolution>.*?<(?:[\w-]+:)?XResolution>(\d+) $b } keys %r];
my %m; $m{trim($1)} = 1 while $x =~ /<(?:[\w-]+:)?ColorMode>([^<]+){res} || [] }; return $want unless @r; my ($b) = sort { abs($a - $want) <=> abs($b - $want) || $b <=> $a } @r; return $b; }
sub pick_mode {
my ($modes, $color, @map) = @_;
my $want = $color eq 'color' ? $map[0] : $color eq 'bw' ? $map[2] : $map[1];
return $want if !@$modes || grep { $_ eq $want } @$modes;
for my $alt ($map[1], $map[0], $map[2]) { return $alt if grep { $_ eq $alt } @$modes; }
return $modes->[0];
}
sub escl_settings {
my ($d, $source, $mode, $dpi, $fmt, $duplex, $full) = @_;
my $s = qq{\n\n$d->{ver}\n};
$s .= qq{Document\n3508escl:ThreeHundredthsOfInches248000\n} if $full;
$s .= qq{$source\n};
$s .= '' . ($duplex ? 'true' : 'false') . "\n" if $source eq 'Feeder';
$s .= qq{$mode\n$dpi$dpi\n$fmt\n};
$s .= qq{$fmt\n} if $full && $d->{fmtext};
return $s . '';
}
# Geräte-Eigenheiten (wie sane-airscan): Epson verlangt den Port im Host-Kopf, Brother braucht beim Einzug eine Pause zwischen den Seiten
sub epson { my $d = shift; return ($d->{name} // '') =~ /^EPSON/i ? 1 : 0; }
sub escl_status { my $d = shift; my ($c, $h, $x) = http(url => "$d->{base}/ScannerStatus", timeout => 5, porthost => epson($d)); return $c == 200 ? $x : ''; }
sub escl_jobs { my $st = shift // ''; my @l; while ($st =~ /<(?:\w+:)?JobInfo>(.*?)<\/(?:\w+:)?JobInfo>/gs) { my $b = $1; my $u = tagv($b, 'JobUri'); push @l, [trim($u), trim(tagv($b, 'JobState') // '')] if $u; } return @l; }
sub escl_scan {
my ($d, $r, $page, $trace) = @_;
my @sources;
if ($r->{source} eq 'platen') { @sources = ('Platen'); }
elsif ($r->{source} eq 'feeder') { @sources = ('Feeder'); }
else {
# automatisch: Papier im Einzug → Einzug, sonst Glas (manche Geräte melden den Einzug nicht in den Fähigkeiten)
my $st = escl_status($d); my $adf = tagv($st, 'AdfState');
my $loaded = !defined $adf ? undef : $adf =~ /Loaded/ ? 1 : $adf =~ /Empty/ ? 0 : undef;
push @$trace, "Einzug: $adf" if defined $adf;
push @sources, 'Feeder' if ($loaded // 0) || (!defined $loaded && $d->{feeder});
push @sources, 'Platen' if $d->{platen} || !($loaded // 0);
}
@sources = ('Platen') unless @sources;
my $dpi = pick_dpi($d, $r->{dpi} || 300);
my $mode = pick_mode($d->{modes}, $r->{color} || 'gray', 'RGB24', 'Grayscale8', 'BlackAndWhite1');
my @formats = (($d->{jpeg} ? 'image/jpeg' : ()), ($d->{pdf} ? 'application/pdf' : ())); @formats = ('image/jpeg') unless @formats;
my $n = 0;
SOURCE: for my $source (@sources) {
my ($loc, $fmt);
VARIANT: for my $f (@formats) { for my $full (1, 0) {
for my $try (1 .. 4) {
my %before = map { $_->[0] => 1 } escl_jobs(escl_status($d));
my ($c, $h, $b) = http(method => 'POST', url => "$d->{base}/ScanJobs", ctype => 'text/xml', body => escl_settings($d, $source, $mode, $dpi, $f, $r->{duplex} && $d->{duplex}, $full), timeout => 30, porthost => epson($d), hosthdr => ($d->{localhost} && host_of($d->{base}) !~ /^(?:127\.|::1$|localhost$)/ ? 'localhost' : undef));
push @$trace, "$source $f " . ($full ? 'voll' : 'einfach') . " ${dpi}dpi $mode → $c";
if ($c == 503 || $c == 409) { sleep 2; next; }
last unless $c >= 200 && $c < 300;
$loc = hdr($h, 'Location') || hdr($h, 'Content-Location');
if (!$loc && $b =~ m{((?:https?://[^\s<"']+)?/eSCL/ScanJobs/[\w.\-]+)}) { $loc = $1; push @$trace, 'Adresse aus Antwort'; }
if (!$loc) { for (1 .. 4) { sleep 1; my @fresh = grep { !$before{ $_->[0] } } escl_jobs(escl_status($d)); if (@fresh) { $loc = $fresh[-1][0]; push @$trace, 'Adresse aus ScannerStatus'; last; } } }
if (!$loc) { push @$trace, 'keine Auftragsadresse'; last; }
$fmt = $f; last VARIANT;
}
} }
next SOURCE unless $loc;
my ($root) = $d->{base} =~ m{^(\w+://[^/]+)};
unless ($loc =~ m{^https?://}) { $loc = $loc =~ m{^/} ? "$root$loc" : "$d->{base}/$loc"; }
$loc =~ s{^\w+://[^/]+}{$root}; # immer über den Weg, auf dem wir das Gerät erreichen (HP meldet „localhost“)
$loc =~ s{/+$}{};
escl_status($d); # Ricoh: ohne Statusabfrage vor der ersten Seite bleibt der Auftrag „wartend“
my $xeroxb = ($d->{name} eq 'B205' || $d->{name} eq 'B215') ? 1 : 0; my $r404 = 0; # Xerox B205/B215: 404/410 nur vorübergehend
my $busy = 0; my $last = 0;
while (1) {
if ($source eq 'Feeder' && $n && ($d->{name} // '') =~ /^Brother /i) { my $w = $last + 1 - time; select(undef, undef, undef, $w > 1 ? 1 : $w) if $w > 0; } # Brother: mind. ca. 1 s zwischen den Seiten
$last = time;
my ($c, $h, $data) = http(url => "$loc/NextDocument", timeout => 120, porthost => epson($d));
if ($c == 503 && $busy++ < 90) { sleep 1; next; }
if (($c == 404 || $c == 410) && $source eq 'Feeder' && !$n && $busy++ < 20) { sleep 1; next; } # zieht noch ein
if (($c == 404 || $c == 410) && $xeroxb && $r404++ < 5) { sleep 1; next; }
last if $c == 404 || $c == 410;
if ($c != 200) { push @$trace, "NextDocument → $c"; last; }
if (length($data) < 100) { push @$trace, 'leere Seite vom Gerät'; last; }
$page->($data, $fmt); $n++; $busy = 0;
last if $source eq 'Platen';
}
last SOURCE if $n;
push @$trace, "$source: keine Seiten";
}
return $n;
}
# ---------------- WSD-Scan (Web Services for Devices, z. B. Dell E525w) ----------------
my $NS = 'xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:sca="http://schemas.microsoft.com/windows/2006/08/wdp/scan" xmlns:wscn="http://schemas.microsoft.com/windows/2006/08/wdp/scan"';
my $SCA = 'http://schemas.microsoft.com/windows/2006/08/wdp/scan';
sub soap {
my ($to, $action, $body) = @_;
return qq{} . esc($to) . qq{$action} . uuid() . qq{http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous$body};
}
sub wsd_call { my ($url, $action, $body, $timeout) = @_; return http(method => 'POST', url => $url, ctype => 'application/soap+xml; charset=utf-8', body => soap($url, $action, $body), timeout => $timeout || 15); }
# WS-Discovery: Probe per Multicast (oder direkt an eine IP), Antwort enthält die Geräteadresse(n)
sub wsd_discover {
my ($target, $secs) = @_;
if (ref $target eq 'ARRAY') { # mehrere IPv4-Adressen über einen Socket
my $s = IO::Socket::INET->new(Proto => 'udp', LocalAddr => '0.0.0.0', LocalPort => 0) or return ();
my $probe = qq{urn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe} . uuid() . qq{wscn:ScanDeviceType};
for my $ip (@$target) { my $a = inet_aton($ip) or next; send($s, $probe, 0, sockaddr_in($WSD_PORT, $a)); }
return wsd_collect([$s], $secs);
}
$target =~ s/^\[|\]$//g if defined $target;
my $v6 = defined $target && $target =~ /:/;
my @socks;
my $sock = $v6 ? undef : IO::Socket::INET->new(Proto => 'udp', LocalAddr => '0.0.0.0', LocalPort => 0);
my $probe = qq{urn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probe} . uuid() . qq{wscn:ScanDeviceType};
if ($sock) { my $ip = inet_aton($target || '239.255.255.250'); if ($ip) { send($sock, $probe, 0, sockaddr_in($WSD_PORT, $ip)) for 1 .. 2; push @socks, $sock; } }
# Ohne Ziel: zusätzlich über jede Netzwerkkarte (sonst nur über die Standardroute – Scanner im WLAN wird bei aktivem LAN/VPN nicht gefunden)
if (!defined $target && (my $mif = eval { Socket::IP_MULTICAST_IF() })) {
for my $lip (local_ipv4()) {
my $s = IO::Socket::INET->new(Proto => 'udp', LocalAddr => $lip, LocalPort => 0) or next;
setsockopt($s, Socket::IPPROTO_IP(), $mif, inet_aton($lip)) or next;
send($s, $probe, 0, sockaddr_in($WSD_PORT, inet_aton('239.255.255.250'))) for 1 .. 2; push @socks, $s;
}
}
# IPv6: an die Adresse bzw. an die WSD-Gruppe ff02::c (Netze ohne IPv4 oder Geräte, die nur über IPv6 antworten)
if ($v6 || !defined $target) {
my ($err, @ai) = getaddrinfo($v6 ? $target : 'ff02::c', $WSD_PORT, { family => AF_INET6, socktype => SOCK_DGRAM });
if (!$err && @ai && socket(my $s6, AF_INET6, SOCK_DGRAM, 0)) { send($s6, $probe, 0, $ai[0]{addr}) for 1 .. 2; push @socks, $s6; }
}
return () unless @socks;
return wsd_collect(\@socks, $secs);
}
sub wsd_collect {
my ($socks, $secs) = @_; my @socks = @$socks;
my $sel = IO::Select->new(@socks); my %found; my $end = time + ($secs || 3);
while ((my $left = $end - time) > 0) {
my @r = $sel->can_read($left); last unless @r;
for my $sk (@r) {
my $from = recv($sk, my $buf, 65535, 0); next unless defined $from;
next unless $buf =~ /ProbeMatch/;
my $epr = trim(tagv($buf, 'Address')) // '';
my @x = grep { m{^http://} } split /\s+/, (tagv($buf, 'XAddrs') // '');
# Link-lokale IPv6-Adresse (fe80::) braucht die Netzwerkschnittstelle – aus dem Absender übernehmen
if (length $from > 16) { my (undef, undef, $scope) = eval { unpack_sockaddr_in6($from) }; s{^http://\[(fe80:[^\]%]+)\]}{http://[$1%25$scope]}i for grep { $scope } @x; }
my $u = pick_url(@x);
$found{$epr} //= { epr => $epr, xaddr => $u } if $u;
$found{$epr}{xaddr} = $u if $u && $found{$epr}{xaddr} =~ m{^\w+://\[} && $u !~ m{^\w+://\[}; # IPv4 ersetzt IPv6
}
}
return values %found;
}
# Gerätebeschreibung holen (WS-Transfer Get) und den Scan-Dienst finden
sub wsd_probe {
my $m = shift;
my ($c, $h, $x) = http(method => 'POST', url => $m->{xaddr}, ctype => 'application/soap+xml; charset=utf-8', timeout => 6, connect => 3,
body => soap($m->{epr} || $m->{xaddr}, 'http://schemas.xmlsoap.org/ws/2004/09/transfer/Get', ''));
return undef unless $c == 200;
$x = decode('UTF-8', $x);
my $svc;
while ($x =~ /<(?:[\w-]+:)?Hosted>(.*?)<\/(?:[\w-]+:)?Hosted>/gs) {
my $hb = $1; next unless $hb =~ /ScannerServiceType/;
my $a = pick_url(map { trim($_) } ($hb =~ /<(?:[\w-]+:)?Address>([^<]+){xaddr} =~ /%25([\w.]+)\]/)[0]; $z ? "$1%25$z]" : "$1]"}ie; last; }
}
return undef unless $svc;
my $name = trim(join ' ', grep { defined && length } (tagv($x, 'Manufacturer'), tagv($x, 'ModelName')));
$name = trim(tagv($x, 'FriendlyName')) if !$name || length $name < 3;
my %d = (kind => 'wsd', id => "wsd:$svc", svc => $svc, name => ($name || 'Scanner ' . host_of($svc)));
# Fähigkeiten: Einzug, beidseitig, Glas, Formate, Farben, Auflösungen
my ($c2, $h2, $e) = wsd_call($svc, "$SCA/GetScannerElements",
'sca:ScannerDescriptionsca:ScannerConfiguration', 8);
if ($c2 == 200) {
$e = decode('UTF-8', $e);
$d{name} = trim(tagv($e, 'ScannerName')) if $name eq '' && tagv($e, 'ScannerName');
$d{feeder} = $e =~ /<(?:[\w-]+:)?ADF\b/ ? 1 : 0;
$d{duplex} = ($e =~ /<(?:[\w-]+:)?ADFSupportsDuplex>\s*(true|1)\s*([^<]+)([^<]+)(.*?)<\/(?:[\w-]+:)?(?:Platen|ADF|ADFFront)Resolutions>/gs) { my $rb = $1; $r{$1} = 1 while $rb =~ /<(?:[\w-]+:)?Width>\s*(\d+)\s* $b } grep { $_ >= 75 && $_ <= 1200 } keys %r];
}
$d{platen} = 1 unless $d{feeder} || $d{platen};
return \%d;
}
# Mehrteilige Antwort (MTOM/XOP) zerlegen: das Bild ist der Binärteil
sub wsd_image {
my ($h, $b) = @_;
my $ct = hdr($h, 'Content-Type') // '';
if ($ct =~ /boundary="?([^";]+)"?/i) {
my $bd = $1; my ($best, $bestlen) = (undef, 0);
for my $part (split /--\Q$bd\E/, $b) {
my ($ph, $pb) = split /\r?\n\r?\n/, $part, 2; next unless defined $pb;
next if $ph =~ m{application/(?:soap\+)?xop\+xml|text/xml|application/soap\+xml}i;
$pb =~ s/\r?\n$//;
($best, $bestlen) = ($pb, length $pb) if length $pb > $bestlen;
}
return $best;
}
if ($b =~ /<(?:[\w-]+:)?ScanData>\s*([A-Za-z0-9+\/=\s]{200,})\s*) { return decode_base64($1); } # eingebettet statt Anhang
return undef;
}
sub wsd_ticket {
my ($d, $src, $fmt, $mode, $dpi, $back, $simple) = @_;
my $itt = $src eq 'Platen' ? 1 : ($d->{name} // '') eq 'RICOH Aficio MP 201' ? 100 : 0; # Ricoh MP 201 versteht 0 nicht
my $side = "$mode$dpi$dpi";
my $t = 'PDFMyPDFMyPDFMy';
$t .= "$fmt";
$t .= "$itt" unless $simple;
$t .= "$src";
$t .= '826811693' unless $simple; # A4 in 1/1000 Zoll
$t .= "$side" . ($back ? "$side" : '') . '';
return $t . '';
}
sub wsd_scan {
my ($d, $r, $page, $trace) = @_;
my @sources = $r->{source} eq 'platen' ? ('Platen') : $r->{source} eq 'feeder' ? ('ADF') : (($d->{feeder} ? 'ADF' : ()), ($d->{platen} ? 'Platen' : ()));
@sources = ('Platen') unless @sources;
my @f = @{ $d->{formats} || [] };
my ($fmt) = (grep({ $_ eq 'jfif' } @f), grep({ $_ eq 'exif' } @f), grep({ /pdf/ } @f), grep({ $_ eq 'png' } @f), 'jfif');
my $mime = $fmt =~ /pdf/ ? 'application/pdf' : $fmt eq 'png' ? 'image/png' : 'image/jpeg';
my $mode = pick_mode($d->{modes} || [], $r->{color} || 'gray', 'RGB24', 'Grayscale8', 'BlackAndWhite1');
my $dpi = pick_dpi($d, $r->{dpi} || 300);
my $n = 0; my $notacc = 0;
SOURCE: for my $src0 (@sources) {
my $src = $src0 eq 'ADF' && $r->{duplex} && $d->{duplex} ? 'ADFDuplex' : $src0;
my ($id, $tok);
for my $simple (0, 1) {
for my $try (1 .. 3) {
my ($c, $h, $x) = wsd_call($d->{svc}, "$SCA/CreateScanJob", wsd_ticket($d, $src, $fmt, $mode, $dpi, $src eq 'ADFDuplex', $simple), 20);
my $fault = trim(tagv($x, 'Value') // ''); $fault = trim(tagv($x, 'Subcode') // $fault) if $x =~ /Subcode/;
push @$trace, "$src $fmt " . ($simple ? 'einfach' : 'voll') . " ${dpi}dpi $mode → $c" . ($c == 200 ? '' : " $fault");
# Lexmark/Xerox melden Fehler auch mit HTTP 200 – nur mit Auftragsnummer und ohne Fault ist es ein Erfolg
my $jid = trim(tagv($x, 'JobId') // '');
if ($c == 200 && length $jid && !wsd_fault($x)) { $id = $jid; $tok = trim(tagv($x, 'JobToken')); last; }
$notacc = 1 if $x =~ /NotAcceptingJobs/i;
last unless $x =~ /Busy|NotAccepting|ServerErrorTemporary|Calibrating|LampWarming/i; sleep 2;
}
last if $id;
}
next SOURCE unless $id;
my $busy = 0;
while (1) {
my ($c, $h, $b) = wsd_call($d->{svc}, "$SCA/RetrieveImage",
"$id" . esc($tok) . 'PDFMy-' . ($n + 1) . '', 180);
if ($c != 200 || wsd_fault($b)) {
last if $b =~ /NoImagesAvailable|JobCompleted|ClientErrorNoImages/i || ($src0 eq 'Platen' && $n);
if ($b =~ /Busy|Temporary|NotReady/i && $busy++ < 60) { sleep 1; next; }
push @$trace, "RetrieveImage → $c " . trim(tagv($b, 'Text') // tagv($b, 'Value') // ''); last;
}
my $img = wsd_image($h, $b);
if (!$img || length $img < 100) { push @$trace, 'kein Bild in der Antwort'; last; }
$page->($img, $mime); $n++; $busy = 0;
last if $src0 eq 'Platen';
}
last SOURCE if $n;
push @$trace, "$src: keine Seiten";
}
die "Der Scanner nimmt gerade keine Aufträge an. Bei Kyocera, Ricoh und manchen anderen Geräten muss WSD-Scan am Gerät gestartet werden: am Bedienfeld „Senden“ bzw. „Scanner“ → „WSD-Scan“ → „Vom Computer“ wählen und dann erneut „Scannen“ klicken.\n" if !$n && $notacc;
return $n;
}
sub wsd_fault { my $x = shift // ''; return $x =~ m{addressing/fault|<(?:[\w-]+:)?Fault\b} ? 1 : 0; }
# ---------------- SANE (nur Linux): USB- und ältere Geräte über scanimage ----------------
sub sane_caps {
my ($si, $dev) = @_;
my $o = run_for(12, $si, '-d', $dev, '-A');
my %d;
my $list = sub { my $k = shift; return () unless $o =~ /--\Q$k\E\s+([^\[\n]+?)\s*(?:\[|\n)/; my $v = $1; $v =~ s/\s*dpi\s*$//i; return map { trim($_) } split /\|/, $v; };
$d{sources} = [$list->('source')]; $d{modes} = [$list->('mode')];
my @r = $list->('resolution');
if (@r == 1 && $r[0] =~ /^(\d+)\.\.(\d+)/) { $d{range} = [$1 + 0, $2 + 0]; $d{res} = [grep { $_ >= $1 && $_ <= $2 } 75, 100, 150, 200, 300, 400, 600]; }
else { $d{res} = [sort { $a <=> $b } grep { /^\d+$/ } @r]; }
return \%d;
}
sub sane_discover {
my $known = shift; # bereits über eSCL/WSD gefundene Geräte – nicht doppelt anzeigen
my $si = which('scanimage') or return ();
my @l;
for (split /\n/, run_for(15, $si, '-f', "%d\t%v %m\n")) {
my ($dev, $name) = split /\t/; next unless $dev && $dev =~ /^[\w-]+:/;
next if $dev =~ /^(?:v4l|test|pnm|net):/; # Webcams, Testtreiber
$name = trim($name); $name =~ s/^Unknown\s+//i;
if ($dev =~ /^(?:airscan|escl):/) { my $n = lc $name; next if grep { my $k = lc $_->{name}; index($k, $n) >= 0 || index($n, $k) >= 0 } @$known; }
my $caps = sane_caps($si, $dev);
my @s = @{ $caps->{sources} };
my %d = (kind => 'sane', id => "sane:$dev", dev => $dev, name => $name || $dev, %$caps);
$d{feeder} = (grep { /adf|feeder|einzug|automatic|document/i } @s) ? 1 : 0;
$d{duplex} = (grep { /duplex/i } @s) ? 1 : 0;
$d{platen} = (grep { /flatbed|platen|glas|normal/i } @s) || !@s ? 1 : 0;
push @l, \%d;
}
return @l;
}
sub sane_mode {
my ($modes, $color) = @_;
return undef unless @$modes;
my @pref = $color eq 'color' ? (qr/colou?r|rgb|farb/i) : $color eq 'bw' ? (qr/lineart|binary|black|halftone|s\/w/i, qr/gr[ae]y/i) : (qr/gr[ae]y|grau/i, qr/colou?r/i);
for my $re (@pref) { my ($m) = grep { $_ =~ $re } @$modes; return $m if defined $m; }
return $modes->[0];
}
sub sane_scan {
my ($d, $r, $page, $trace) = @_;
my $si = which('scanimage') or do { push @$trace, 'scanimage fehlt'; return 0; };
my @s = @{ $d->{sources} || [] };
my ($flat) = grep { /flatbed|platen|glas|normal/i } @s;
my ($dup) = grep { /duplex/i } @s;
my ($adf) = grep { /adf|feeder|einzug|automatic|document/i && !/duplex|back|rück/i } @s;
my $feed = $r->{duplex} && $dup ? $dup : ($adf // $dup);
my @try = $r->{source} eq 'platen' ? ($flat) : $r->{source} eq 'feeder' ? ($feed) : (($feed // ()), ($flat // ()));
@try = (undef) unless grep { defined } @try;
my $dpi = $r->{dpi} || 300;
if ($d->{range}) { $dpi = $d->{range}[0] if $dpi < $d->{range}[0]; $dpi = $d->{range}[1] if $dpi > $d->{range}[1]; } else { $dpi = pick_dpi($d, $dpi); }
my $mode = sane_mode($d->{modes} || [], $r->{color} || 'gray');
my $n = 0;
SRC: for my $src (@try) {
my $isflat = !defined $src || (defined $flat && $src eq $flat);
for my $fmt ('jpeg', 'png') {
my $dir = "$DIR/jobs/sane-$$"; remove_tree($dir); make_path($dir);
my @cmd = ($si, '-d', $d->{dev}, "--format=$fmt", '--resolution', $dpi, (defined $mode ? ('--mode', $mode) : ()), (defined $src ? ('--source', $src) : ()),
"--batch=$dir/p%04d.$fmt", '--batch-print', ($isflat ? ('--batch-count=1') : ()));
my $pid = open(my $fh, '-|');
if (!defined $pid) { push @$trace, 'scanimage startet nicht'; return $n; }
if ($pid == 0) { open STDERR, '>', "$dir/err.txt"; { exec @cmd }; POSIX::_exit(127); }
my $got = 0;
while (my $line = <$fh>) { chomp $line; next unless -f $line; my $data = slurp($line); unlink $line; next unless defined $data && length $data > 100; $page->($data, $fmt eq 'png' ? 'image/png' : 'image/jpeg'); $n++; $got++; }
close $fh;
my $err = trim(slurp("$dir/err.txt") // ''); $err =~ s/\s+/ /g; remove_tree($dir);
push @$trace, 'SANE ' . ($src // 'Standard') . " $fmt ${dpi}dpi " . ($mode // '') . " → $got Seite(n)" . ($err ? ' · ' . substr($err, 0, 160) : '');
last unless !$got && $err =~ /format|unrecognized|not supported|unbekannt/i; # nur bei fehlendem JPEG-Format mit PNG wiederholen
}
last SRC if $n;
}
return $n;
}
# ---------------- Suche ----------------
# Einzelne Geräteabfrage absichern: ein Fehler (z. B. ungewöhnliche WSD-Antwort) darf die Suche nicht abbrechen
sub safe { my $r = eval { $_[0]->() }; if ($@) { (my $e = "$@") =~ s/\s+$//; logl("Suche: $e"); } return $r; }
# Schlafende Geräte wecken: kurze Verbindungsversuche an die bekannten Adressen (Web, IPP, RAW-Druck, WSD) und Wake-on-LAN,
# wenn die Hardware-Adresse im ARP-Speicher steht. Viele Drucker/MFP antworten im Tiefschlaf nicht auf den WSD-Rundruf.
sub mac_of {
my $ip = shift; return undef unless $ip =~ /^\d+\.\d+\.\d+\.\d+$/;
my $o = $IS_MAC ? run_for(2, '/usr/sbin/arp', '-n', $ip) : (slurp('/proc/net/arp') // '');
if (!$IS_MAC) { for (split /\n/, $o) { my @f = split ' '; return $f[3] if @f > 3 && $f[0] eq $ip && $f[3] =~ /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i && $f[3] ne '00:00:00:00:00:00'; } return undef; }
my ($m) = $o =~ /\bat\s+([0-9a-f]{1,2}(?::[0-9a-f]{1,2}){5})\b/i; return undef unless $m;
return join ':', map { sprintf '%02x', hex } split /:/, $m;
}
sub wake_host {
my $h = shift; $h =~ s/^\[|\]$//g;
for my $port (80, 443, 631, 9100, 5357, 8080) { my $s = IO::Socket::INET->new(PeerAddr => $h, PeerPort => $port, Proto => 'tcp', Timeout => 0.4); close $s if $s; }
my $mac = mac_of($h) or return;
my $pkt = ("\xFF" x 6) . (pack('H12', join '', split /:/, $mac) x 16);
for my $to ('255.255.255.255', $h) { my $u = IO::Socket::INET->new(Proto => 'udp', PeerAddr => $to, PeerPort => 9, Broadcast => 1); if ($u) { $u->send($pkt); close $u; } }
}
sub wake_known {
my %h;
for my $d (@{ devices() }) { my $x = host_of($d->{base} // $d->{svc} // $d->{xaddr} // ''); $h{$x} = 1 if $x; }
for (@{ added_ips() }) { my ($ip) = split /\|/; $h{$ip} = 1 if $ip; }
delete @h{ '127.0.0.1', 'localhost', '' };
my @l = sort keys %h; return 0 unless @l;
my @kids; for my $x (@l) { my $k = fork(); if (defined $k && $k == 0) { eval { wake_host($x) }; POSIX::_exit(0); } push @kids, $k if $k; }
waitpid($_, 0) for @kids;
logl('Wecken: ' . scalar(@l) . ' Gerät(e) angesprochen (' . join(', ', @l) . ')');
return scalar @l;
}
sub discover {
my %dev; my @t;
select(undef, undef, undef, 1.5) if !$ENV{PDFMY_WOKEN} && safe(sub { wake_known() }); # schlafende Geräte zuerst wecken (nicht doppelt nach /wake)
# eSCL über Bonjour (macOS: dns-sd)
if ($IS_MAC && -x '/usr/bin/dns-sd') {
my %names;
for my $type ('_uscan._tcp') {
for (split /\n/, run_for(2.5, '/usr/bin/dns-sd', '-B', $type, 'local.')) { $names{$1} = $type if /\sAdd\s+\d+\s+\d+\s+\S+\s+\Q$type\E\.\s+(.+?)\s*$/; }
}
for my $inst (keys %names) {
my $o = run_for(2, '/usr/bin/dns-sd', '-L', $inst, $names{$inst}, 'local.');
my ($host, $port) = $o =~ /can be reached at\s+(\S+?)\.?:(\d+)/; next unless $host;
my ($rs) = $o =~ /\brs=([^\s"\\]+)/; $rs //= 'eSCL';
my $d = safe(sub { escl_probe("http://$host:$port/$rs") }); $dev{ $d->{id} } = $d if $d;
}
}
my @diag; push @diag, 'Netz: ' . (join(', ', local_ipv4()) || 'keine IPv4-Adresse');
push @diag, 'Bonjour: ' . scalar(keys %dev) if $IS_MAC;
# eSCL über Avahi (Linux): -r auflösen, -p maschinenlesbar, -t nach dem ersten Durchlauf beenden
push @diag, 'avahi-browse fehlt (Paket avahi-utils)' if !$IS_MAC && !which('avahi-browse');
if (!$IS_MAC && (my $ab = which('avahi-browse'))) {
my (%svc, %seen);
for my $type ('_uscan._tcp', '_uscans._tcp') {
for (split /\n/, run_for(5, $ab, '-rpt', $type)) {
my @f = split /;/, $_, 10; next unless @f >= 9 && $f[0] eq '=';
my ($if, $fam, $name, $addr, $port, $txt) = @f[1, 2, 3, 7, 8, 9]; $txt //= '';
$addr .= "%$if" if $addr =~ /^fe80:/i && $if; # link-lokal: Schnittstelle angeben
my ($rs) = $txt =~ /"rs=([^"]*)"/; $rs = 'eSCL' unless defined $rs && length $rs;
push @{ $svc{"$type|$name"} }, [$fam, (($type eq '_uscans._tcp' ? 'https' : 'http') . '://' . hosturl($addr) . ":$port/$rs")];
}
}
# je Gerät: IPv4 zuerst, IPv6 nur als Rückfall; http vor https
for my $k (sort keys %svc) {
my ($name) = $k =~ /\|(.*)$/; next if $seen{$name};
for my $u (map { $_->[1] } sort { ($a->[0] eq 'IPv4' ? 0 : 1) <=> ($b->[0] eq 'IPv4' ? 0 : 1) } @{ $svc{$k} }) { my $d = safe(sub { escl_probe($u) }); if ($d) { $dev{ $d->{id} } = $d; $seen{$name} = 1; last; } }
}
}
push @diag, 'avahi: ' . scalar(keys %dev) if !$IS_MAC && which('avahi-browse');
# WSD (Multicast)
my @wm = @{ safe(sub { [wsd_discover(undef, 3)] }) || [] }; push @diag, 'WSD-Rundruf: ' . scalar(@wm) . ' Antwort(en)';
for my $m (@wm) { my $d = safe(sub { wsd_probe($m) }); $dev{ $d->{id} } = $d if $d && !grep { lc $_->{name} eq lc $d->{name} && $_->{kind} eq 'escl' } values %dev; }
# Netzsuche (wie pdfmy Scan für Windows): jede Adresse im eigenen Netz direkt fragen – findet Geräte, wenn Bonjour/avahi fehlen
# oder eine Firewall die Antworten auf den Rundruf verwirft (unter Linux häufig: firewalld/ufw)
my %known = map { host_of($_->{base} // $_->{svc} // $_->{xaddr} // '') => 1 } values %dev;
my $open = safe(sub { subnet_hosts() }) || {}; my @hosts = grep { !$known{$_} } sort keys %$open; my @wsdHosts;
for my $ip (@hosts) {
my $d; for my $base (($open->{$ip}{80} ? "http://$ip/eSCL" : ()), ($open->{$ip}{443} ? "https://$ip/eSCL" : ())) { $d = safe(sub { escl_probe($base) }) and last; }
if ($d) { $dev{ $d->{id} } = $d; } else { push @wsdHosts, $ip; }
}
my @wu = @wsdHosts ? @{ safe(sub { [wsd_discover(\@wsdHosts, 2)] }) || [] } : ();
for my $m (@wu) { my $d = safe(sub { wsd_probe($m) }); $dev{ $d->{id} } = $d if $d && !grep { lc $_->{name} eq lc $d->{name} } values %dev; }
push @diag, 'Netzsuche: ' . scalar(keys %$open) . ' Gerät(e) mit Web-/Druckerport' . (@hosts ? ' (' . join(', ', @hosts[0 .. ($#hosts > 11 ? 11 : $#hosts)]) . ')' : '') . ', WSD direkt: ' . scalar(@wu);
# Von Hand hinzugefügte Geräte (IP-Adresse)
for my $ip (@{ added_ips() }) { my $d = safe(sub { probe_ip($ip) }); $dev{ $d->{id} } = $d if $d; }
# Linux: SANE für USB- und ältere Geräte (nur was nicht schon über eSCL/WSD gefunden wurde)
if (!$IS_MAC) { for my $d (@{ safe(sub { [sane_discover([values %dev])] }) || [] }) { $dev{ $d->{id} } = $d; } }
my @l = sort { ($a->{kind} cmp $b->{kind}) || ($a->{name} cmp $b->{name}) } values %dev;
save_devices(\@l);
logl('Suche: ' . scalar(@l) . ' Scanner – ' . join(', ', map { "$_->{name} ($_->{kind})" } @l) . ' · ' . join(' · ', @diag));
return \@l;
}
sub probe_ip {
my ($ip, $port) = split /\|/, shift;
# Gerätename ohne Punkt (z. B. EPSON2CBBCB): Windows löst ihn selbst auf, Mac/Linux nur als Name.local (Bonjour)
if ($ip =~ /^[A-Za-z][\w-]*$/ && !gethostbyname($ip)) { my $l = "$ip.local"; my $r = gethostbyname($l) ? $l : undef;
if (!$r && (my $o = $IS_MAC ? run_for(2, '/usr/bin/dscacheutil', '-q', 'host', '-a', 'name', $l) : (which('avahi-resolve') ? run_for(3, which('avahi-resolve'), '-4', '-n', $l) : ''))) { ($r) = $o =~ /(\d{1,3}(?:\.\d{1,3}){3})/; }
$ip = $r if $r; logl("Gerätename $l → " . ($r // 'nicht gefunden')); }
my $h = hosturl($ip);
for my $base ($port ? ("http://$h:$port/eSCL", "https://$h:$port/eSCL") : ("http://$h/eSCL", "https://$h/eSCL", "http://$h:8080/eSCL")) { my $d = escl_probe($base); return $d if $d; }
for my $m (wsd_discover($ip, 2)) { my $d = wsd_probe($m); return $d if $d; }
return undef;
}
# ---------------- Scanaufträge (laufen in eigenem Prozess, Zustand als Dateien) ----------------
sub job_state { my $id = shift; my $s = slurp("$DIR/jobs/$id/state.json"); return $s ? eval { $J->decode($s) } : undef; }
sub start_scan {
my ($d, $r) = @_;
my $lock = slurp("$DIR/busy"); return (409, { error => 'Es läuft bereits ein Scan.' }) if $lock && alive(trim($lock));
for my $old (glob "$DIR/jobs/*") { remove_tree($old) if -M $old > 0.02; } # älter als ca. 30 Minuten
my $id = substr(join('', map { sprintf '%02x', int rand 256 } 1 .. 8), 0, 12);
make_path("$DIR/jobs/$id");
my $mime = $d->{kind} eq 'escl' && !$d->{jpeg} && $d->{pdf} ? 'application/pdf' : 'image/jpeg'; # SANE/WSD melden das Format je Seite
spit("$DIR/jobs/$id/state.json", $J->encode({ state => 'running', pages => 0, mime => $mime }));
my $pid = fork();
return (500, { error => 'Scan konnte nicht gestartet werden.' }) unless defined $pid;
if ($pid == 0) {
spit("$DIR/busy", $$);
my @trace; my $n = 0;
my $page = sub { my ($data, $m) = @_; spit("$DIR/jobs/$id/$n.bin", $data); $n++; spit("$DIR/jobs/$id/state.json", $J->encode({ state => 'running', pages => $n, mime => $m || $mime })); $mime = $m || $mime; };
my $ok = eval { $d->{kind} eq 'wsd' ? wsd_scan($d, $r, $page, \@trace) : $d->{kind} eq 'sane' ? sane_scan($d, $r, $page, \@trace) : escl_scan($d, $r, $page, \@trace) };
my $err = $@ ? "$@" : '';
my $trace = join ' | ', @trace;
logl("Scan $d->{name}: $n Seite(n) · $trace" . ($err ? " · Fehler: $err" : ''));
my %s = (state => 'done', pages => $n, mime => $mime, detail => $trace);
if (!$n) { %s = (%s, state => 'error', error => $err || 'Der Scanner hat keine Seiten geliefert. Liegt Papier im Einzug oder auf dem Glas? Ist das Gerät gerade frei (kein Kopier- oder Druckauftrag)?'); }
spit("$DIR/jobs/$id/state.json", $J->encode(\%s));
unlink "$DIR/busy";
POSIX::_exit(0);
}
return (200, { job => $id, device => $d->{name} });
}
# ---------------- PDFs per Doppelklick (Finder bzw. Dateimanager) in PDFMy öffnen ----------------
# Die App „PDFMy“ (vom Installer angelegt) schickt den Dateipfad an /open-local. Die Datei bekommt ein Einmal-Kennwort
# (5 Minuten gültig), der Browser öffnet pdfmy.de#open= und holt sie über 127.0.0.1 ab. Die Datei verlässt den Rechner nicht.
# Linux: Der Dienst hat keinen Zugriff auf die Bildschirmsitzung – die Adresse geht an das Startskript zurück, das den Browser öffnet.
my $BUNDLE = 'de.pdfmy.open';
sub app_url { my $u = trim(slurp("$DIR/appurl.txt") // ''); return (grep { $u =~ /^\Q$_\E\// } @ALLOWED) ? $u : 'https://pdfmy.de/'; }
# Ist PDFMy in Chrome oder Edge als App installiert (macOS legt sie unter ~/Applications/… Apps.localized ab),
# öffnet die PDF direkt im App-Fenster – der Browser übergibt die Datei an PDFMy (launchQueue).
sub pwa_app {
for my $d ('Chrome Apps.localized', 'Microsoft Edge Apps.localized', 'Edge Apps.localized', 'Chromium Apps.localized') { my $a = "$ENV{HOME}/Applications/$d/PDFMy.app"; return $a if -d $a; }
return undef;
}
# Anhänge aus Outlook/Mail liegen im geschützten Ordner der jeweiligen App (~/Library/Containers/…, den Chrome nicht lesen darf)
# oder in Temp-Ordnern, die die App bald wieder leert. Sofort eine Kopie im eigenen Ordner anlegen
# (je Kopie ein Unterordner, der Dateiname bleibt; nach einem Tag entfernt).
sub volatile { my $p = shift; return $p =~ m{/Library/(?:Group )?Containers/|^(?:/private)?/var/folders/|^(?:/private)?/tmp/} || (length($ENV{TMPDIR} // '') && index($p, $ENV{TMPDIR}) == 0); }
sub secure_copy {
my $path = shift; my $base = "$DIR/open-copies";
return $path if !volatile($path) || index($path, "$base/") == 0;
make_path($base); for my $old (glob "\"$base\"/*") { remove_tree($old) if -M $old > 1; }
my $dir = "$base/" . int(time) . "-$$"; make_path($dir);
my ($nm) = $path =~ m{([^/]+)$}; my $dst = "$dir/$nm";
if (File::Copy::copy($path, $dst) && -s $dst) { logl("Kopie für PDFMy: $dst"); return $dst; }
logl("Kopie nicht möglich: $path ($!)"); return $path;
}
# PDFMy (Browser-App) durfte eine übergebene Datei nicht lesen – z. B. weil die Chrome-App „PDFMy“ selbst Standard für PDFs ist und
# Outlook ihr den Pfad im geschützten Ordner gibt: frisch abgelegte Datei gleichen Namens in den Anhang-Ordnern suchen
# (eigene Kopien, Outlook, Apple Mail, Thunderbird, Temp) – nur PDF/Bilder, höchstens 30 Minuten alt.
sub find_recent {
my $name = shift // ''; $name =~ s{.*/}{};
return undef unless length $name && $name =~ /\.(?:pdf|jpe?g|png|tiff?|webp|bmp)$/i;
my $nfc = sub { my $s = shift; utf8::decode($s); return Unicode::Normalize::NFC($s); }; # macOS legt Namen oft zerlegt (NFD) ab
my $want = $nfc->($name); my $H = $ENV{HOME} // '';
# Nur Ordner, in denen Mail-Programme Anhänge zum Öffnen ablegen – nicht das Postfach selbst (zu groß); [Ordner, Tiefe]
my $O = "$H/Library/Containers/com.microsoft.Outlook/Data";
my @roots = (["$DIR/open-copies", 2]);
if ($IS_MAC) { push @roots, ["$O/tmp", 6], ["$O/Library/Caches", 6], ["$O/Downloads", 3], ["$H/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", 3], ["$H/Library/Mail Downloads", 3], [$ENV{TMPDIR} // '', 4], ["$H/Downloads", 1]; }
else { push @roots, (map { [$_, 4] } glob('/tmp/mozilla_*')), ["$H/.cache/thunderbird", 4], [$ENV{TMPDIR} // '', 3], ['/tmp', 3]; }
my $cut = time - 1800; my ($best, $bt); my %seen; my %denied; my $stop = time + 8; my $n = 0; # Zeitgrenze: höchstens 8 Sekunden suchen
my $walk; $walk = sub {
my ($d, $left) = @_; return if time > $stop || ++$n > 100000;
opendir(my $dh, $d) or do { $denied{$d} = 1 if $!{EPERM} || $!{EACCES}; return }; my @e = grep { $_ ne '.' && $_ ne '..' } readdir $dh; closedir $dh;
for my $e (@e) {
last if time > $stop;
next if $left <= 0 && $nfc->($e) ne $want; # unterste Ebene: nur der gesuchte Name zählt (spart Dateiabfragen)
my $p = "$d/$e"; next if -l $p;
if (-d _) { $walk->($p, $left - 1) if $left > 0; next; }
next unless $nfc->($e) eq $want;
my @s = stat $p; ($best, $bt) = ($p, $s[9]) if @s && $s[9] >= $cut && $s[7] > 0 && (!$bt || $s[9] > $bt);
}
};
for my $r (grep { length $_->[0] && -d $_->[0] && !$seen{ $_->[0] }++ } @roots) { $walk->(@$r); last if $best; } # Ordner nach Wahrscheinlichkeit: erster Treffer genügt
our %FIND_DENIED = %denied; # für die Antwort an PDFMy (genaue Meldung bei macOS-Datenschutz)
if ($best) { logl("Anhang für PDFMy nachgereicht: $best"); }
else { logl("Anhang nicht gefunden: $name – gesucht in " . join('; ', grep { -d } map { $_->[0] } @roots) . (%denied ? ' – kein Zugriff (macOS-Datenschutz) auf: ' . join('; ', sort keys %denied) : '')); } # fürs Protokoll / „Problem melden“
return $best;
}
sub open_local {
my $path = shift;
return 0 unless defined $path && -f $path && -r _;
$path = secure_copy($path);
if ($IS_MAC && -x '/usr/bin/open' && !$ENV{PDFMY_TEST_NOBROWSER} and my $app = pwa_app()) {
if (system('/usr/bin/open', '-a', $app, $path) == 0) { logl("Öffnen in der PDFMy-App: $path"); return 'app'; }
}
make_path("$DIR/open");
for my $old (glob "$DIR/open/*") { unlink $old if -M $old > 0.0035; } # älter als 5 Minuten
my $tok = join '', map { sprintf '%02x', int rand 256 } 1 .. 16;
spit("$DIR/open/$tok", $path);
my $url = app_url() . "#open=$tok";
system('/usr/bin/open', $url) if $IS_MAC && -x '/usr/bin/open';
logl("Öffnen: $path");
return $url;
}
sub take_file {
my $tok = shift; my $f = "$DIR/open/$tok";
return (undef, 'Der Link ist abgelaufen – bitte die Datei noch einmal doppelt anklicken.') unless $tok =~ /^[0-9a-f]{32}$/ && -f $f && -M $f <= 0.0035;
my $path = slurp($f); unlink $f;
utf8::decode($path);
my $data = slurp($path); return (undef, 'Die Datei ist nicht mehr vorhanden.') unless defined $data;
my ($name) = $path =~ m{([^/]+)$};
return ($data, $name);
}
# Ist PDFMy das Standardprogramm für PDFs? (macOS LaunchServices, über JavaScript for Automation; 60 Sekunden zwischengespeichert)
# „In Office-Programm öffnen“: installierte Programme und was man damit machen kann (der Nutzer wählt in PDFMy).
# Mac: Programme-Ordner, Linux: Befehle im Pfad. Die PDF liegt danach in $DIR/office (lokal, nicht in iCloud/OneDrive).
sub office_apps {
my @l; my $t = sub { $ENV{ 'PDFMY_TEST_OFFICE_' . uc $_[0] } }; # nur für Tests
if ($IS_MAC) {
my $app = sub { for my $d ('/Applications', "$ENV{HOME}/Applications") { return "$d/$_[0].app" if -d "$d/$_[0].app"; } return undef; };
if (my $a = $t->('onlyoffice') || $app->('ONLYOFFICE')) { push @l, { id => 'onlyoffice', app => 'ONLYOFFICE', what => 'pdf', cmd => ['/usr/bin/open', '-a', $a] }; }
for ([ 'libreoffice', 'LibreOffice', 'LibreOffice' ], [ 'collabora', 'Collabora Office', 'Collabora Office' ]) {
my ($id, $name, $label) = @$_; my $a = $t->($id) || $app->($name) or next;
my $so = -x "$a/Contents/MacOS/soffice" ? "$a/Contents/MacOS/soffice" : $a;
push @l, { id => "$id-writer", app => "$label Writer", what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] },
{ id => "$id-draw", app => "$label Draw", what => 'draw', cmd => ['/usr/bin/open', '-a', $a] };
}
# SoftMaker FreeOffice / SoftMaker Office (TextMaker), Apache OpenOffice: Word-Dokument aus PDFMy; Inkscape: Grafik
my $glob = sub { for my $d ('/Applications', "$ENV{HOME}/Applications") { my ($a) = reverse sort glob("'$d/'*$_[0]*.app"); return $a if $a && -d $a; } return undef; };
if (my $a = $t->('textmaker') || $glob->('TextMaker')) { push @l, { id => 'textmaker', app => ($a =~ /FreeOffice/i ? 'FreeOffice TextMaker' : 'SoftMaker TextMaker'), what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] }; }
if (my $a = $t->('openoffice') || $app->('OpenOffice')) { push @l, { id => 'openoffice-writer', app => 'Apache OpenOffice Writer', what => 'docx', fmt => 'docx', cmd => ['/usr/bin/open', '-a', $a] }; }
if (my $a = $t->('inkscape') || $app->('Inkscape')) { push @l, { id => 'inkscape', app => 'Inkscape', what => 'graphic', cmd => ['/usr/bin/open', '-a', $a] }; }
if (my $a = $t->('word') || $app->('Microsoft Word')) { push @l, { id => 'word', app => 'Microsoft Word', what => 'word', cmd => ['/usr/bin/open', '-a', $a] }; }
} else {
my $oo = $t->('onlyoffice') || which('onlyoffice-desktopeditors') || which('desktopeditors') || (-x '/opt/onlyoffice/desktopeditors/DesktopEditors' ? '/opt/onlyoffice/desktopeditors/DesktopEditors' : undef);
push @l, { id => 'onlyoffice', app => 'ONLYOFFICE', what => 'pdf', cmd => [$oo] } if $oo;
if (my $lo = $t->('libreoffice') || which('libreoffice') || which('soffice')) {
push @l, { id => 'libreoffice-writer', app => 'LibreOffice Writer', what => 'docx', fmt => 'docx', cmd => [$lo, '--norestore'] },
{ id => 'libreoffice-draw', app => 'LibreOffice Draw', what => 'draw', cmd => [$lo, '--norestore'] };
}
if (my $tm = $t->('textmaker') || which('textmaker24free') || which('textmaker24') || which('textmaker21free') || which('textmaker21')) {
push @l, { id => 'textmaker', app => ($tm =~ /free/ ? 'FreeOffice TextMaker' : 'SoftMaker TextMaker'), what => 'docx', fmt => 'docx', cmd => [$tm] };
}
if (my $ao = $t->('openoffice') || which('openoffice4')) { push @l, { id => 'openoffice-writer', app => 'Apache OpenOffice Writer', what => 'docx', fmt => 'docx', cmd => [$ao, '-writer'] }; }
if (my $ik = $t->('inkscape') || which('inkscape')) { push @l, { id => 'inkscape', app => 'Inkscape', what => 'graphic', cmd => [$ik] }; }
}
return @l;
}
sub office_open {
my ($id, $name, $pdf) = @_;
my ($c) = grep { $_->{id} eq ($id // '') } office_apps();
return (undef, 'Dieses Programm ist auf diesem Rechner nicht (mehr) installiert.') unless $c;
my $docx = ($c->{fmt} // 'pdf') eq 'docx'; my $ext = $docx ? '.docx' : '.pdf';
return (undef, $docx ? 'Kein gültiges Word-Dokument.' : 'Keine gültige PDF-Datei.') unless defined $pdf && substr($pdf, 0, $docx ? 2 : 4) eq ($docx ? 'PK' : '%PDF');
my $dir = "$DIR/office"; make_path($dir);
my $base = $name // ''; $base =~ s/\.(pdf|docx)$//i; $base =~ s{[/\\:*?"<>|\x00-\x1f]+}{ }g; $base =~ s/\s+/ /g; $base = trim($base);
$base = 'Dokument' unless length $base; $base = substr($base, 0, 80);
my $f = "$dir/$base$ext"; for (my $n = 2; -e $f; $n++) { $f = "$dir/$base ($n)$ext"; }
open my $h, '>:raw', $f or return (undef, "Datei lässt sich nicht anlegen: $!"); print $h $pdf; close $h;
my $k = fork();
return (undef, "$c->{app} ließ sich nicht starten.") unless defined $k;
if ($k == 0) { POSIX::setsid(); open STDIN, '<', '/dev/null'; open STDOUT, '>', '/dev/null'; open STDERR, '>', '/dev/null'; exec(@{ $c->{cmd} }, $f) or POSIX::_exit(1); }
logl("Office: $base$ext → $c->{app}");
return ($f, undef);
}
# „In Cloud-Ordner speichern“: Sync-Ordner der Programme, die der Nutzer selbst installiert hat (Nextcloud, ownCloud, Seafile, Dropbox,
# OneDrive, Google Drive, iCloud). pdfmy Scan legt die PDF im Unterordner „PDFMy“ ab – hochladen tut das Sync-Programm des Nutzers.
sub cloud_folders {
my (@l, %seen); my $H = $ENV{HOME} // '';
my $add = sub {
my ($id, $name, $p) = @_; return unless defined $p && length $p; $p =~ s{/+$}{}; return unless -d $p && !$seen{$p}++;
my $n = grep { $_->{id} eq $id || index($_->{id}, "$id-") == 0 } @l;
push @l, { id => $n ? "$id-" . ($n + 1) : $id, name => $name, path => $p };
};
if (my $t = $ENV{PDFMY_TEST_CLOUD}) { for (split /;/, $t) { my ($n, $p) = split /\|/, $_, 2; $add->('test', $n, $p); } return @l; } # nur für Tests
my $cfg = sub { # Nextcloud/ownCloud-Programm: „0\Folders\1\localPath=/Users/…/Nextcloud/“
my ($f) = @_; open my $h, '<:encoding(UTF-8)', $f or return ();
my @p; while (<$h>) { push @p, $1 if /^\d+\\Folders(?:WithPlaceholders)?\\\d+\\localPath=(.+?)\s*$/; } close $h; return @p;
};
my $conf = $IS_MAC ? "$H/Library/Preferences" : ($ENV{XDG_CONFIG_HOME} || "$H/.config");
$add->('nextcloud', 'Nextcloud', $_) for $cfg->("$conf/Nextcloud/nextcloud.cfg");
$add->('owncloud', 'ownCloud', $_) for $cfg->("$conf/ownCloud/owncloud.cfg");
$add->('seafile', 'Seafile', "$H/Seafile");
if (open my $h, '<', "$H/.dropbox/info.json") { # Dropbox: { "personal": { "path": … }, "business": { … } }
local $/; my $o = eval { $J->decode(<$h>) } || {}; close $h;
for my $k (sort keys %$o) { $add->('dropbox', $k eq 'business' ? 'Dropbox (Firma)' : 'Dropbox', $o->{$k}{path}) if ref $o->{$k} eq 'HASH'; }
}
if ($IS_MAC) { # macOS: Datei-Anbieter unter ~/Library/CloudStorage (OneDrive, Google Drive, Dropbox, Nextcloud …) und iCloud Drive
for my $d (sort glob("'$H/Library/CloudStorage/'*")) {
my $b = (split m{/}, $d)[-1]; my ($k) = $b =~ /^([A-Za-z]+)/; next unless $k;
my %nm = (OneDrive => 'OneDrive', GoogleDrive => 'Google Drive', Dropbox => 'Dropbox', Nextcloud => 'Nextcloud', ownCloud => 'ownCloud', Box => 'Box', pCloud => 'pCloud');
my $p = $d; if ($k eq 'GoogleDrive') { ($p) = grep { -d } ("$d/My Drive", "$d/Meine Ablage"); $p //= $d; }
$add->(lc $k, ($nm{$k} // $k) . ($b =~ /^OneDrive-(?!Personal)(.+)$/ ? " ($1)" : ''), $p);
}
$add->('icloud', 'iCloud Drive', "$H/Library/Mobile Documents/com~apple~CloudDocs");
} else {
$add->('onedrive', 'OneDrive', "$H/OneDrive");
}
return @l;
}
sub cloud_save {
my ($id, $name, $pdf) = @_;
my ($c) = grep { $_->{id} eq ($id // '') } cloud_folders();
return (undef, 'Diesen Cloud-Ordner gibt es auf diesem Rechner nicht (mehr).') unless $c;
return (undef, 'Keine gültige PDF-Datei.') unless defined $pdf && substr($pdf, 0, 4) eq '%PDF';
my $dir = "$c->{path}/PDFMy"; eval { make_path($dir) }; return (undef, "Ordner lässt sich nicht anlegen: $!") unless -d $dir;
my $base = $name // ''; $base =~ s/\.pdf$//i; $base =~ s{[/\\:*?"<>|\x00-\x1f]+}{ }g; $base =~ s/\s+/ /g; $base = trim($base); $base =~ s/\.+$//;
$base = 'Dokument' unless length $base; $base = substr($base, 0, 120);
my $f = "$dir/$base.pdf"; for (my $n = 2; -e $f; $n++) { $f = "$dir/$base ($n).pdf"; }
open my $h, '>:raw', $f or return (undef, "Speichern nicht möglich: $!"); print $h $pdf; close $h;
logl("Cloud-Ordner: $base.pdf → $c->{name}");
return ($f, undef);
}
sub cloud_show { # abgelegte Datei im Finder bzw. Dateimanager zeigen – nur eigene Ablagen
my ($f) = @_; return unless defined $f && -f $f;
return unless grep { index($f, "$_->{path}/PDFMy/") == 0 } cloud_folders();
my @cmd = $IS_MAC ? ('/usr/bin/open', '-R', $f) : ((which('xdg-open') // return), ($f =~ m{^(.*)/[^/]+$})[0]);
my $k = fork(); return unless defined $k;
if ($k == 0) { POSIX::setsid(); open STDIN, '<', '/dev/null'; open STDOUT, '>', '/dev/null'; open STDERR, '>', '/dev/null'; exec(@cmd) or POSIX::_exit(1); }
}
# Mac: Eintrag „In PDFMy öffnen“ im PDF-Menü des Druckdialogs (Skript in ~/Library/PDF Services, legt die Einrichtung an)
sub print_service {
return 'none' unless $IS_MAC;
opendir(my $d, "$ENV{HOME}/Library/PDF Services") or return 'off';
my $on = grep { /PDFMy/ } readdir $d; closedir $d;
return $on ? 'on' : 'off';
}
# Linux: Freedesktop-Standard ~/.config/mimeapps.list, Abschnitt [Default Applications] (gilt für GNOME, KDE, Xfce, Cinnamon …)
my $DESKTOP = 'pdfmy.desktop';
sub mimeapps { return ($ENV{XDG_CONFIG_HOME} || "$ENV{HOME}/.config") . '/mimeapps.list'; }
sub linux_default {
my $f = slurp(mimeapps()) // ''; my $sec = '';
for (split /\n/, $f) { if (/^\s*\[(.+)\]/) { $sec = $1; next; } return (trim((split /;/, $1)[0] // '') eq $DESKTOP ? 1 : 0) if $sec eq 'Default Applications' && /^\s*application\/pdf\s*=\s*(.*)$/; }
return 0;
}
sub linux_set_default {
my $p = mimeapps(); my @l = split /\n/, (slurp($p) // ''); my ($sec, $done, @out) = ('', 0);
for (@l) {
if (/^\s*\[(.+)\]/) { push @out, "application/pdf=$DESKTOP;" if $sec eq 'Default Applications' && !$done++; $sec = $1; push @out, $_; next; }
if ($sec eq 'Default Applications' && /^\s*application\/pdf\s*=/) { push @out, "application/pdf=$DESKTOP;" unless $done++; next; }
push @out, $_;
}
push @out, "application/pdf=$DESKTOP;" if $sec eq 'Default Applications' && !$done++;
push @out, (@out ? '' : ()), '[Default Applications]', "application/pdf=$DESKTOP;" unless $done;
make_path(($p =~ m{^(.*)/[^/]+$})[0]); spit($p, join("\n", @out) . "\n");
return linux_default();
}
sub pdf_default {
my $c = "$DIR/pdfdefault.txt";
return trim(slurp($c)) eq '1' if -f $c && -M $c < 0.0007;
if (!$IS_MAC) { my $is = linux_default(); spit($c, $is); return $is; }
return 0 unless -x '/usr/bin/osascript';
my $o = run_for(3, '/usr/bin/osascript', '-l', 'JavaScript', '-e', 'ObjC.import("CoreServices"); ObjC.unwrap($.LSCopyDefaultRoleHandlerForContentType($("com.adobe.pdf"), 0xFFFFFFFF)) || ""');
my $is = lc(trim($o)) eq $BUNDLE ? 1 : 0; spit($c, $is); return $is;
}
sub set_pdf_default {
if (!$IS_MAC) { my $ok = linux_set_default(); spit("$DIR/pdfdefault.txt", $ok); logl('Standard für PDFs setzen (mimeapps.list): ' . ($ok ? 'ok' : 'fehlgeschlagen')); return $ok; }
return 0 unless -x '/usr/bin/osascript';
my $o = run_for(5, '/usr/bin/osascript', '-l', 'JavaScript', '-e', qq{ObjC.import("CoreServices"); \$.LSSetDefaultRoleHandlerForContentType(\$("com.adobe.pdf"), 0xFFFFFFFF, \$("$BUNDLE"))});
unlink "$DIR/pdfdefault.txt";
logl('Standard für PDFs setzen: ' . trim($o));
return pdf_default();
}
# ---------------- HTTP-Server (nur 127.0.0.1) ----------------
sub send_resp {
my ($c, $code, $type, $data, $extra) = @_;
my %txt = (200 => 'OK', 204 => 'No Content', 403 => 'Forbidden', 404 => 'Not Found', 409 => 'Conflict');
my $h = "HTTP/1.1 $code " . ($txt{$code} || 'Error') . "\r\n";
$h .= "Content-Type: $type\r\n" if $type;
$h .= 'Content-Length: ' . length($data) . "\r\nCache-Control: no-store\r\nConnection: close\r\n";
$h .= "$_: $extra->{$_}\r\n" for sort keys %{ $extra || {} };
print $c $h, "\r\n", $data;
}
sub send_json { my ($c, $code, $o, $extra) = @_; send_resp($c, $code, 'application/json; charset=utf-8', $J->encode($o), $extra); }
# Wem gehört das andere Ende der Verbindung? Auf Rechnern mit mehreren Konten erreichen alle Benutzer 127.0.0.1 –
# zugelassen werden nur Prozesse des eigenen Benutzers (KONZEPT.md, 25.10.2026). Nicht ermittelbar → abgelehnt.
sub peer_own {
my $cport = shift; return 0 unless $cport;
if ($IS_MAC) {
# lsof sieht als normaler Benutzer nur eigene Prozesse; -a -u schränkt zusätzlich darauf ein
my $lsof = -x '/usr/sbin/lsof' ? '/usr/sbin/lsof' : which('lsof'); return 0 unless $lsof;
my $o = run_for(5, $lsof, '-nP', '-a', '-u', $<, "-iTCP\@127.0.0.1:$cport", '-sTCP:ESTABLISHED', '-Fn');
return $o =~ /^n127\.0\.0\.1:$cport->127\.0\.0\.1:$PORT$/m ? 1 : 0;
}
# Linux: /proc/net/tcp nennt zu jeder Verbindung den Besitzer (Spalte uid); gesucht ist das Ende cport → PORT im Zustand ESTABLISHED
for my $f ($ENV{PDFMY_TEST_PROC_TCP} // ('/proc/net/tcp', '/proc/net/tcp6')) {
open my $h, '<', $f or next;
while (<$h>) {
my @c = split;
next unless @c > 7 && $c[3] eq '01' && $c[1] =~ /:([0-9A-Fa-f]{4})$/ && hex($1) == $cport && $c[2] =~ /:([0-9A-Fa-f]{4})$/ && hex($1) == $PORT;
close $h; return $c[7] == $< ? 1 : 0;
}
close $h;
}
return 0;
}
our %CORS_NOW;
sub handle {
my $c = shift; binmode $c;
local $SIG{ALRM} = sub { die "timeout\n" }; alarm 15;
my $head = '';
while ($head !~ /\r\n\r\n/) { my $n = sysread($c, my $b, 4096); last unless $n; $head .= $b; return if length $head > 32768; }
my ($hdrs, $rest) = split /\r\n\r\n/, $head, 2; $rest //= '';
my ($line, @hl) = split /\r\n/, $hdrs; my ($method, $path) = split / /, $line // '';
return unless $method;
my %h; for (@hl) { $h{ lc $1 } = $2 if /^([^:]+):\s*(.*)$/; }
# Nur Prozesse des eigenen Benutzers – vor jeder anderen Prüfung, auch für /open-local und OPTIONS
if (!peer_own($c->peerport)) {
my ($p0) = split /\?/, $path // ''; logl("Abgelehnt: $method $p0 (anderer Benutzer oder nicht ermittelbar)");
send_resp($c, 403, 'text/plain', 'forbidden');
# Rest der Anfrage verwerfen (höchstens 1 MB) und sauber schließen – sonst sieht der Absender statt 403 einen Verbindungsabbruch
shutdown($c, 1); alarm 2; my $got = 0; eval { while ($got < 1_000_000) { my $n = sysread($c, my $b, 16384); last unless $n; $got += $n; } }; alarm 0;
return;
}
# Host-Kopf nur die eigene Adresse (Schutz gegen DNS-Rebinding), Herkunft nur PDFMy – geprüft, bevor der Inhalt gelesen wird
my $origin = $h{origin};
my $hostok = defined $h{host} && grep { lc $h{host} eq $_ } ("127.0.0.1:$PORT", "localhost:$PORT");
my $ok = $hostok && (!defined $origin || grep { lc $_ eq lc $origin } @ALLOWED);
if (!$ok) { alarm 0; return send_resp($c, 403, 'text/plain', 'forbidden'); }
my $len = $h{'content-length'} || 0; return if $len > ($path =~ m{^/(office|cloud)$} ? 300_000_000 : 1_000_000); # PDFs für „In Office-Programm öffnen“ dürfen groß sein
my $body = $rest; while (length $body < $len) { my $n = sysread($c, my $b, $len - length $body); last unless $n; $body .= $b; }
alarm 0;
my %cors; if (defined $origin && $ok) { %cors = ('Access-Control-Allow-Origin' => $origin, 'Vary' => 'Origin'); }
%CORS_NOW = %cors;
if ($method eq 'OPTIONS') {
return send_resp($c, 403, 'text/plain', 'forbidden') unless $ok;
return send_resp($c, 204, undef, '', { %cors, 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers' => 'Content-Type, X-Pdfmy', 'Access-Control-Allow-Private-Network' => 'true', 'Access-Control-Max-Age' => '600' });
}
my ($p) = split /\?/, $path;
# Doppelklick im Finder: nur vom eigenen Mac (ohne Origin, also nicht aus einer Webseite)
if ($p eq '/open-local' && $method eq 'POST' && !defined $origin) {
my $path = $body =~ /^\s*\{/ ? (eval { $J->decode($body)->{path} } // '') : $body; $path =~ s/[\r\n]+$//;
my $url = open_local($path);
return send_json($c, $url ? 200 : 404, $url ? { ok => JSON::PP::true, url => $url } : { error => 'Datei nicht gefunden' });
}
# Schutz: nur PDFMy (Origin) und nur mit eigenem Kopf – fremde Seiten können nichts auslösen oder lesen
return send_resp($c, 403, 'text/plain', 'forbidden', \%cors) if !$ok || ($p ne '/status' && !exists $h{'x-pdfmy'});
my $in = sub { my $o = eval { $J->decode($body) }; return ref $o eq 'HASH' ? $o : {}; };
if ($p eq '/status') {
# PDFMy meldet seine Adresse (pdfmy.de oder Testversion): dorthin öffnet ein Doppelklick die PDF
if ($path =~ /[?&]app=([^&]+)/) { my $u = $1; $u =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge; spit("$DIR/appurl.txt", $u) if grep { $u =~ /^\Q$_\E\// } @ALLOWED; }
my $l = devices();
# /status muss sofort antworten: Standard-PDF-Programm nur aus dem Zwischenspeicher, Auffrischen im Hintergrund
my $cf = "$DIR/pdfdefault.txt"; my $def = -f $cf && trim(slurp($cf)) eq '1';
if (!-f $cf || -M $cf >= 0.0007) { my $k = fork(); if (defined $k && $k == 0) { pdf_default(); POSIX::_exit(0); } }
return send_json($c, 200, { app => 'pdfmy-scan', version => $VERSION, ownUser => JSON::PP::true, platform => lc $OS, tsa => JSON::PP::true, searching => JSON::PP::false, devices => scalar(@$l), printer => print_service(), pdfDefault => $def ? JSON::PP::true : JSON::PP::false }, \%cors);
}
if ($p =~ m{^/file/(\w+)$} && $method eq 'GET') {
my ($data, $name) = take_file($1);
return send_json($c, 404, { error => $name }, \%cors) unless defined $data;
my $enc = $name; utf8::encode($enc); $enc =~ s/([^A-Za-z0-9._~-])/sprintf '%%%02X', ord $1/ge;
return send_resp($c, 200, 'application/pdf', $data, { %cors, 'Access-Control-Expose-Headers' => 'X-Pdfmy-Name', 'X-Pdfmy-Name' => $enc });
}
if ($p eq '/attachment' && $method eq 'GET') { # PDFMy durfte eine übergebene Datei nicht lesen (z. B. Outlook-Anhang): hier nachreichen
my ($q) = $path =~ /[?&]name=([^&]*)/; $q //= ''; $q =~ s/\+/ /g; $q =~ s/%([0-9A-Fa-f]{2})/chr hex $1/ge;
my $f = safe(sub { find_recent($q) });
our %FIND_DENIED; return send_json($c, 404, { error => 'Datei nicht gefunden', denied => %FIND_DENIED ? JSON::PP::true : JSON::PP::false }, \%cors) unless $f;
return send_json($c, 413, { error => 'Die Datei ist zu groß (über 300 MB).' }, \%cors) if -s $f > 300 * 1024 * 1024;
my ($name) = $f =~ m{([^/]+)$}; my $enc = $name; $enc =~ s/([^A-Za-z0-9._~-])/sprintf '%%%02X', ord $1/ge;
return send_resp($c, 200, 'application/octet-stream', slurp($f), { %cors, 'Access-Control-Expose-Headers' => 'X-Pdfmy-Name', 'X-Pdfmy-Name' => $enc });
}
if ($p eq '/tsa' && $method eq 'POST') { # Zeitstempel (RFC 3161) für Signaturen: Browser dürfen Zeitstempeldienste nicht direkt abfragen (kein CORS)
my $o = $in->(); my $url = $o->{url} // '';
# nur bekannte, öffentliche Zeitstempeldienste – kein offener Proxy (gleiche Liste wie im Windows-Programm)
my ($host) = $url =~ m{^https?://([^/:?#]+)(?:/[^\s]*)?$}i;
return send_json($c, 400, { error => 'Unbekannter Zeitstempeldienst' }, \%cors)
unless $host && grep { lc $host eq $_ } qw(timestamp.digicert.com timestamp.sectigo.com zeitstempel.dfn.de freetsa.org timestamp.globalsign.com ts.ssl.com timestamp.entrust.net timestamp.identrust.com tsa.swisssign.net);
my $req = eval { decode_base64($o->{data} // '') } // '';
return send_json($c, 400, { error => 'Ungültige Daten' }, \%cors) if !length $req || length $req > 4096;
my ($fh, $qf) = tempfile(DIR => $DIR, SUFFIX => '.tsq', UNLINK => 1); binmode $fh; print $fh $req; close $fh;
my ($rh, $rf) = tempfile(DIR => $DIR, SUFFIX => '.tsr', UNLINK => 1); close $rh;
my $code = system('curl', '-s', '-f', '-m', '20', '--max-filesize', '65536', '-H', 'Content-Type: application/timestamp-query', '--data-binary', "\@$qf", '-o', $rf, $url);
my $d = slurp($rf) // ''; unlink $qf, $rf;
if ($code != 0 || !length $d) { logl("Zeitstempel: Dienst $host antwortet nicht"); return send_json($c, 502, { error => "Zeitstempeldienst $host antwortet nicht" }, \%cors); }
logl("Zeitstempel von $host (" . length($d) . ' Bytes)');
return send_json($c, 200, { data => encode_base64($d, '') }, \%cors);
}
if ($p eq '/office' && $method eq 'GET') { return send_json($c, 200, { apps => [ map { +{ id => $_->{id}, app => $_->{app}, what => $_->{what}, fmt => $_->{fmt} // 'pdf' } } office_apps() ] }, \%cors); } # installierte Office-Programme
if ($p eq '/office' && $method eq 'POST') { # PDF mit dem gewählten Office-Programm öffnen (Datei bleibt auf dem Rechner)
my $o = $in->(); my ($f, $err) = office_open($o->{id}, $o->{name}, eval { decode_base64($o->{data} // '') });
return send_json($c, $err ? 409 : 200, { ok => $err ? JSON::PP::false : JSON::PP::true, error => $err, path => $f }, \%cors);
}
if ($p eq '/cloud' && $method eq 'GET') { return send_json($c, 200, { folders => [ cloud_folders() ] }, \%cors); } # Sync-Ordner (Nextcloud, Dropbox, OneDrive …)
if ($p eq '/cloud' && $method eq 'POST') { # PDF im Unterordner „PDFMy“ des Cloud-Ordners ablegen – hochladen tut das Sync-Programm
my $o = $in->(); my ($f, $err) = cloud_save($o->{id}, $o->{name}, eval { decode_base64($o->{data} // '') });
return send_json($c, $err ? 409 : 200, { ok => $err ? JSON::PP::false : JSON::PP::true, error => $err, path => $f }, \%cors);
}
if ($p eq '/cloud/show' && $method eq 'POST') { my $o = $in->(); cloud_show($o->{path}); return send_json($c, 200, { ok => JSON::PP::true }, \%cors); }
if ($p eq '/defaultapps' && $method eq 'POST') { my $ok = set_pdf_default(); return send_json($c, 200, { ok => JSON::PP::true, registered => $ok ? JSON::PP::true : JSON::PP::false, isDefault => $ok ? JSON::PP::true : JSON::PP::false }, \%cors); }
if ($p eq '/wake' && $method eq 'POST') { # „Scanner aufwecken“: wecken, kurz warten, neu suchen
my $n = safe(sub { wake_known() }) || 0; sleep 3 if $n;
my $l = eval { local $ENV{PDFMY_WOKEN} = 1; discover() } || devices();
return send_json($c, 200, { woken => $n, devices => scalar(@$l) }, \%cors);
}
if ($p eq '/scanners' && $method eq 'GET') {
my $l = devices(); my $err;
if ($path =~ /refresh=1/ || !@$l) { my $n = eval { discover() }; if ($n) { $l = $n; } else { ($err = "$@") =~ s/\s+$//; logl("Suche fehlgeschlagen: $err"); } }
return send_json($c, 200, { searching => JSON::PP::false, ($err ? (error => "Die Scannersuche ist abgebrochen: $err") : ()), scanners => [map { { id => $_->{id}, name => $_->{name}, kind => $_->{kind}, feeder => $_->{feeder} ? JSON::PP::true : JSON::PP::false, duplex => $_->{duplex} ? JSON::PP::true : JSON::PP::false, platen => $_->{platen} ? JSON::PP::true : JSON::PP::false } } @$l] }, \%cors);
}
if ($p eq '/scanners/add' && $method eq 'POST') {
# IPv4, IPv6 (auch [fe80::1%eth0]) oder Gerätename (z. B. EPSON2CBBCB oder EPSON2CBBCB.local)
my $ip = trim($in->()->{ip} // ''); $ip =~ s{^\w+://}{}; $ip =~ s{^(\[[^\]]+\][^/]*|[^/]+)/.*$}{$1};
my $port;
if ($ip =~ /^\[([^\]]+)\](?::(\d+))?$/) { ($ip, $port) = ($1, $2); } # [IPv6] oder [IPv6]:Port
elsif ($ip =~ /^([^:]+):(\d+)$/) { ($ip, $port) = ($1, $2); } # IPv4/Name:Port
return send_json($c, 404, { error => 'Bitte eine gültige IP-Adresse oder einen Gerätenamen angeben.' }, \%cors) unless $ip =~ /^[\w.\-]+$/ || $ip =~ /^[0-9a-f:.]+(?:%[\w.\-]+)?$/i;
$ip .= "|$port" if $port;
my $d = probe_ip($ip);
return send_json($c, 404, { error => 'Unter dieser Adresse antwortet kein Scanner (eSCL oder WSD). Bitte am Gerät „AirPrint/eSCL/Mopria“ oder „WSD-Scan“ aktivieren.' . ($IS_MAC ? '' : ' Für USB-Scanner unter Linux: SANE installieren (z. B. sudo apt install sane-utils sane-airscan ipp-usb).') }, \%cors) unless $d;
my $a = added_ips(); push @$a, $ip unless grep { $_ eq $ip } @$a; spit("$DIR/added.json", $J->encode($a));
my $l = [grep { $_->{id} ne $d->{id} } @{ devices() }]; push @$l, $d; save_devices($l);
logl("Hinzugefügt: $ip → $d->{name} ($d->{kind})");
return send_json($c, 200, { id => $d->{id}, name => $d->{name}, kind => $d->{kind}, feeder => $d->{feeder} ? JSON::PP::true : JSON::PP::false, duplex => $d->{duplex} ? JSON::PP::true : JSON::PP::false }, \%cors);
}
if ($p eq '/scan' && $method eq 'POST') {
my $r = $in->(); my $d = find_dev($r->{id} // '');
return send_json($c, 404, { error => 'Kein Scanner gefunden.' }, \%cors) unless $d;
my ($code, $o) = start_scan($d, { source => $r->{source} || 'auto', duplex => $r->{duplex} ? 1 : 0, color => $r->{color} || 'gray', dpi => $r->{dpi} || 300 });
return send_json($c, $code, $o, \%cors);
}
if ($p =~ m{^/scan/([0-9a-f]{6,16})(?:/page/(\d+))?$}) {
my ($id, $idx) = ($1, $2); my $s = job_state($id);
return send_json($c, 404, { error => 'Unbekannter Scanauftrag.' }, \%cors) unless $s;
return send_json($c, 200, { state => $s->{state}, pages => $s->{pages} + 0, error => $s->{error}, mime => $s->{mime}, detail => $s->{detail}, note => '' }, \%cors) unless defined $idx;
my $data = slurp("$DIR/jobs/$id/$idx.bin");
return send_json($c, 404, { error => 'Seite nicht vorhanden.' }, \%cors) unless defined $data;
return send_resp($c, 200, $s->{mime} || 'image/jpeg', $data, \%cors);
}
# net: interne IP-Adressen dieses Rechners – für den Fehlerbericht (Scanner im selben Netz?)
if ($p eq '/log') { my @z = split /\n/, decode('UTF-8', slurp("$DIR/log.txt") // ''); return send_json($c, 200, { version => $VERSION, lines => [@z[($#z > 39 ? $#z - 39 : 0) .. $#z]], net => join(', ', local_ipv4()), host => eval { require Sys::Hostname; Sys::Hostname::hostname() } // '' }, \%cors); }
if ($p eq '/certs') { return send_json($c, 200, { certs => [] }, \%cors); }
return send_json($c, 404, { error => 'Nicht gefunden' }, \%cors);
}
# Ersten freien Port ab 52880 nehmen: sind mehrere Benutzer angemeldet, hat jeder seinen eigenen pdfmy Scan.
# Die Web-App sucht 52880–52889 ab; die Helfer der anderen Benutzer antworten ihr mit 403.
my $srv;
for my $p ($ENV{PDFMY_PORT} ? ($PORT) : (52880 .. 52889)) {
$srv = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => $p, Proto => 'tcp', Listen => 20, ReuseAddr => 1) and do { $PORT = $p; last };
}
$srv or do { logl("Start nicht möglich (Ports 52880–52889 belegt?): $!"); die "pdfmy Scan: Ports belegt\n"; };
spit("$DIR/port", "$PORT\n"); # Doppelklick-App, Druck-Dienst und Einrichtung lesen hier den eigenen Port
logl("pdfmy Scan für $OS $VERSION gestartet (Port $PORT" . ($PORT != 52880 ? ' – 52880 ist belegt, anderer Benutzer oder anderes Programm' : '') . ')');
while (1) {
my $c = $srv->accept or next;
my $pid = fork();
if (!defined $pid) { eval { handle($c) }; close $c; next; }
if ($pid == 0) {
close $srv; eval { handle($c) };
# Unerwarteter Fehler: verständliche Antwort statt abgebrochener Verbindung (Browser: „Failed to fetch“)
if ($@ && $@ ne "timeout\n") { (my $e = "$@") =~ s/\s+$//; logl("HTTP: $e"); eval { send_json($c, 500, { error => "pdfmy Scan: interner Fehler – $e" }, \%CORS_NOW) }; }
close $c; POSIX::_exit(0);
}
close $c;
}
PDFMY_PERL_END
if ! "$PERL" -c "$DIR/pdfmy-scan.pl" >/dev/null 2>&1; then echo " ✗ Das Programm ist beschädigt. Bitte neu herunterladen."; exit 1; fi
ok=""
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
cat > "$UNIT" </dev/null 2>&1
systemctl --user restart pdfmy-scan.service
STARTER="systemctl --user restart pdfmy-scan.service"
else
# Ohne systemd (z. B. manche Minimal-Systeme): Start bei der Anmeldung über den Autostart der Arbeitsoberfläche
mkdir -p "$CONF/autostart"
cat > "$CONF/autostart/pdfmy-scan.desktop" </dev/null 2>&1; sleep 1
nohup "$PERL" "$DIR/pdfmy-scan.pl" >"$DIR/fehler.txt" 2>&1 &
STARTER="( nohup $PERL \"$DIR/pdfmy-scan.pl\" >/dev/null 2>&1 & )"
fi
for i in 1 2 3 4 5 6 7 8; do sleep 1; if curl -s -m 2 "http://127.0.0.1:$(cat "$DIR/port" 2>/dev/null || echo 52880)/status" | grep -q pdfmy-scan; then ok=1; break; fi; done
if [ -n "$ok" ]; then echo " ✓ Scan-Dienst läuft (startet künftig mit der Anmeldung)"; else echo " ✗ Der Scan-Dienst startet nicht. Details: journalctl --user -u pdfmy-scan bzw. $DIR/log.txt"; fi
# 2. „PDFMy“ im Anwendungsmenü: öffnet PDFs per Doppelklick in PDFMy (die Datei bleibt auf dem Rechner)
cat > "$DIR/pdfmy-open" </dev/null || echo 52880)/open-local"; } # eigener Port (bei mehreren angemeldeten Benutzern ggf. 52881–52889)
# Als App installiertes PDFMy (Chrome/Edge legen dafür eine .desktop-Datei mit --app-id an): Datei direkt im App-Fenster öffnen
pwa() {
for d in "\${XDG_DATA_HOME:-\$HOME/.local/share}"/applications/*.desktop; do
[ -f "\$d" ] && grep -q '^Name=PDFMy\$' "\$d" && grep -q -- '--app-id=' "\$d" && grep -q 'application/pdf' "\$d" && { printf '%s' "\$d"; return 0; }
done
return 1
}
app=\$(pwa)
for f in "\$@"; do
p=\$(readlink -f -- "\$f" 2>/dev/null || printf '%s' "\$f")
if [ -n "\$app" ]; then
e=\$(sed -n 's/^Exec=//p' "\$app" | head -n 1 | sed 's/ %[UuFf]//g')
if [ -n "\$e" ]; then sh -c "\$e \"\\\$1\"" pdfmy "\$p" >/dev/null 2>&1 & continue; fi
fi
r=\$(send "\$p") || { $STARTER; sleep 2; r=\$(send "\$p"); }
u=\$(printf '%s' "\$r" | sed -n 's/.*"url":"\\([^"]*\\)".*/\\1/p')
if [ -n "\$u" ]; then xdg-open "\$u" >/dev/null 2>&1 &
else
m="PDFMy konnte die Datei nicht öffnen, weil pdfmy Scan nicht läuft. Bitte die Einrichtung (pdfmy-scan-linux.sh) noch einmal starten."
command -v notify-send >/dev/null 2>&1 && notify-send "PDFMy" "\$m" || { command -v zenity >/dev/null 2>&1 && zenity --error --text="\$m"; } || echo "\$m" >&2
fi
done
OPEN
chmod +x "$DIR/pdfmy-open"
curl -sf -m 10 -o "$DIR/pdfmy.png" https://pdfmy.de/icons/app-512.png || true
cat > "$APPS/pdfmy.desktop" </dev/null 2>&1 && update-desktop-database "$APPS" >/dev/null 2>&1
echo " ✓ „PDFMy“ im Anwendungsmenü und unter „Öffnen mit“ angelegt"
# 3. Standard für PDF-Dateien?
ans=""
if { : /dev/null; then printf " Sollen PDF-Dateien künftig per Doppelklick in PDFMy geöffnet werden? [J/n] "; read -r ans /dev/null 2>&1; then xdg-mime default pdfmy.desktop application/pdf >/dev/null 2>&1; fi
curl -s -m 5 -X POST -H 'Origin: https://pdfmy.de' -H 'X-Pdfmy: 1' "http://127.0.0.1:$(cat "$DIR/port" 2>/dev/null || echo 52880)/defaultapps" >/dev/null 2>&1
echo " ✓ PDFMy ist jetzt Ihr Standard für PDF-Dateien (änderbar im Dateimanager: Rechtsklick → Öffnen mit)";;
esac
# 4. Hinweise zu Scannern
echo ""
if ! command -v avahi-browse >/dev/null 2>&1; then echo " ℹ Für die automatische Suche nach Netzwerkscannern empfohlen: avahi-utils (Paket „avahi-utils“ bzw. „avahi-tools“)."; fi
if ! command -v scanimage >/dev/null 2>&1; then
echo " ℹ Netzwerkscanner (eSCL/AirScan, WSD) funktionieren ohne Treiber. Für USB-Scanner und ältere Geräte zusätzlich SANE:"
if command -v apt >/dev/null 2>&1; then echo " sudo apt install sane-utils sane-airscan ipp-usb"
elif command -v dnf >/dev/null 2>&1; then echo " sudo dnf install sane-backends sane-airscan ipp-usb"
elif command -v zypper >/dev/null 2>&1; then echo " sudo zypper install sane-backends sane-airscan ipp-usb"
elif command -v pacman >/dev/null 2>&1; then echo " sudo pacman -S sane sane-airscan ipp-usb"
else echo " Pakete: sane-backends (scanimage), sane-airscan, ipp-usb"; fi
fi
echo ""
if [ -n "$ok" ]; then
echo " Fertig. In PDFMy heißt der Knopf oben jetzt „Scannen“."
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then xdg-open "https://pdfmy.de/" >/dev/null 2>&1 & fi
fi
echo " Entfernen: sh pdfmy-scan-linux-entfernen.sh (von pdfmy.de/scanner.html#linux)"
echo ""