diff --git a/frontend/index.html b/frontend/index.html index 1e3f7e2..ca5c0bb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1932,7 +1932,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.30.2'; +const SIMPLE_VERSION = '3.30.3'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== diff --git a/frontend/sw.js b/frontend/sw.js index 2f9b173..d95417f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v452'; +const CACHE = 'simple-v453'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index fa1ef86..05e8eaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.30.2" +version = "3.30.3" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py index b6981a0..2d7bce3 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -28,6 +28,37 @@ def _log_stdout(msg): os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode()) +def _guard_console_streams(): + """Never let a non-encodable character kill the server. + + On Windows the launcher pipes stdout/stderr into PowerShell (see start.bat), + so Python encodes with the ANSI code page instead of the wide console API - + cp1251 cannot encode the banner's box-drawing rule (U+2500) or the APDU + trace arrows (U+2192), and one such character raised UnicodeEncodeError and + killed the process. errors="replace" keeps every message, whatever the code + page (a character the code page lacks prints as '?').""" + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(errors="replace") + except Exception: + pass + + +def _console_rule(n=70): + """The banner rule in the widest character the output stream can encode. + + Under start.bat stdout is a pipe (not a console), so Python encodes with the + ANSI code page - cp1251 (Russian Windows) cannot encode the box-drawing + U+2500 and falls back to a plain ASCII rule (cp437 has the box-drawing + characters and keeps the pretty one).""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + try: + "\u2500".encode(enc) + return "\u2500" * n + except (UnicodeEncodeError, LookupError): + return "-" * n + + def _default_web_dir(): # /frontend, whether run from source or an editable install. return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend') @@ -56,6 +87,7 @@ def _build_http_server(host, port, handler): def main(): global _server_start + _guard_console_streams() _server_start = time.time() mod = load_pysim_app() parser = mod.option_parser @@ -167,7 +199,7 @@ def main(): # Normal cardless start: there was no card in the reader (the # 3s wait timed out). The presence monitor auto-equips once a # card appears; nothing to recover, no traceback. - sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n') + sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n') except Exception: print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr) traceback.print_exc() @@ -176,13 +208,13 @@ def main(): except NoCardError: # The fallback retried the cardless wait; still a normal # cardless start, not an initialization failure. - sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n') + sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n') else: try: sl.wait_for_card(3) rs, card = mod.init_card(sl, opts.skip_card_init) except NoCardError: - sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n') + sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n') _tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000)) if card is not None: # Fast init builds the card on its own SimCardCommands instance; @@ -361,11 +393,11 @@ def main(): # that event triggers auto-equip instead — the desired retry. if sl is not None and getattr(sl, '_reader', None) is not None: start_card_monitor(str(sl._reader)) - print("─" * 70) + print(_console_rule()) print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port)) print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)." % (opts.http_host, opts.http_port)) - print("─" * 70) + print(_console_rule()) try: server.serve_forever() except KeyboardInterrupt: diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index b4eb463..44b23b4 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.30.2' +VERSION = '3.30.3' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -9203,7 +9203,7 @@ class PysimHandler(BaseHTTPRequestHandler): elif is_equip: output += ('EQUIP: the shell did not register its command sets - ' 'see the output above\n') - sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed)) + sys.stderr.write("CMD: %s -> %s (%dms)\n" % (cmd, status, elapsed)) resp = {'output': output, 'stop': bool(stop)} self._send_json(resp) self._log_resp(resp) @@ -9221,13 +9221,13 @@ class PysimHandler(BaseHTTPRequestHandler): data, sw = scc.send_apdu_checksw(apdu_hex) elapsed = int((time.time() - t0) * 1000) resp = {'response': data, 'sw': sw} - sys.stderr.write("APDU: %s → SW: %s (%dms)\n" % (apdu_hex, sw, elapsed)) + sys.stderr.write("APDU: %s -> SW: %s (%dms)\n" % (apdu_hex, sw, elapsed)) self._send_json(resp) self._log_resp(resp) except Exception as e: elapsed = int((time.time() - t0) * 1000) err = {'error': str(e)} - sys.stderr.write("APDU: %s → ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed)) + sys.stderr.write("APDU: %s -> ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed)) self._send_json(err, 500) self._log_resp(err) elif self.path == '/api/verify-adm': @@ -9253,12 +9253,12 @@ class PysimHandler(BaseHTTPRequestHandler): # the ADM is verified for the current card session self.server.adm_session = getattr(self.server, 'card_session', 0) self.server.adm_failed_session = None - sys.stderr.write('VERIFY ADM → SW: %s\n' % resp.get('sw')) + sys.stderr.write('VERIFY ADM -> SW: %s\n' % resp.get('sw')) self._send_json(resp) self._log_resp(resp) except Exception as e: resp = {'ok': False, 'error': str(e)} - sys.stderr.write('VERIFY ADM → ERROR: %s\n' % e) + sys.stderr.write('VERIFY ADM -> ERROR: %s\n' % e) self._send_json(resp, 500) self._log_resp(resp) elif self.path == '/api/esim/profile': @@ -10357,7 +10357,7 @@ class PysimHandler(BaseHTTPRequestHandler): return block_size = block_size_req or 240 boundary, total_blocks = _cap_compat_blocks(loadfile_data, block_size) - sys.stderr.write('CAP-COMPAT: %s — the import list ends in LOAD %d/%d ' + sys.stderr.write('CAP-COMPAT: %s - the import list ends in LOAD %d/%d ' '(block size %d)\n' % (loadfile_aid, boundary, total_blocks, block_size)) @@ -10430,7 +10430,7 @@ class PysimHandler(BaseHTTPRequestHandler): resp['failed_step'] = len(steps) failed_rec = steps[-1] if steps else {} resp.update(_cntr_low_fields(failed_rec.get('por_status'))) - sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % ( + sys.stderr.write('CAP-COMPAT: %s - imports %s (LOADS to %d/%d)\n' % ( loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks)) self._send_json(resp) self._log_resp(resp) diff --git a/tests/test_console_encoding.py b/tests/test_console_encoding.py new file mode 100644 index 0000000..091cdd3 --- /dev/null +++ b/tests/test_console_encoding.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Tests for the Windows console-encoding guard (v3.30.3). + +Under start.bat the server's stdout/stderr are pipes into PowerShell's +Tee-Object, so Python encodes with the ANSI code page instead of the wide +console API. The banner's box-drawing rule (U+2500) and the APDU-trace arrows +(U+2192) are not encodable in cp1251 (Russian Windows), and one such character +raised UnicodeEncodeError and killed the process before it could listen. The +guard sets errors="replace" on both streams and the banner picks the widest +rule character its stream can encode. +""" + +import ast +import inspect +import io +import pathlib +import sys +import unittest + +from pysim_simple_server import __main__ as srv_main + + +class ConsoleEncodingTests(unittest.TestCase): + def setUp(self): + self._stdout, self._stderr = sys.stdout, sys.stderr + + def tearDown(self): + sys.stdout, sys.stderr = self._stdout, self._stderr + + @staticmethod + def _pipe(encoding='cp1251'): + """A strict text stream over a pipe-like buffer - what start.bat gives + the server on Windows (a pipe, so Python uses the ANSI code page).""" + raw = io.BytesIO() + return io.TextIOWrapper(raw, encoding=encoding), raw + + def test_a_strict_cp1251_pipe_dies_on_the_banner(self): + # the control: the pre-fix failure is real, so the tests below are + # meaningful (a fix that forgets the rule would be caught here) + out, _ = self._pipe() + with self.assertRaises(UnicodeEncodeError): + out.write('\u2500' * 70) + + def test_the_rule_falls_back_to_ascii_on_a_cp1251_pipe(self): + out, _ = self._pipe() + sys.stdout = out + rule = srv_main._console_rule() + self.assertEqual(rule, '-' * 70) + self.assertTrue(rule.isascii()) + + def test_the_rule_stays_pretty_on_a_utf8_stream(self): + out, _ = self._pipe(encoding='utf-8') + sys.stdout = out + self.assertEqual(srv_main._console_rule(), '\u2500' * 70) + + def test_the_guard_keeps_the_banner_and_the_traces_alive(self): + out, out_raw = self._pipe() + err, err_raw = self._pipe() + sys.stdout, sys.stderr = out, err + srv_main._guard_console_streams() + print(srv_main._console_rule()) # the listening banner + sys.stderr.write('CMD: EQUIP \u2192 ok\n') # the APDU trace + sys.stderr.write('CAP-COMPAT: x \u2014 y\n') # the CAP em dash + out.flush() + err.flush() + self.assertEqual(out_raw.getvalue().decode('cp1251').strip(), '-' * 70) + # the arrow cannot be encoded - it prints as '?', never raising; the + # em dash is in cp1251 and stays + self.assertEqual(err_raw.getvalue().decode('cp1251').splitlines(), + ['CMD: EQUIP ? ok', 'CAP-COMPAT: x \u2014 y']) + + def test_the_guard_tolerates_streams_without_reconfigure(self): + sys.stdout = io.StringIO() + srv_main._guard_console_streams() # must not raise + print('still works') + self.assertEqual(sys.stdout.getvalue(), 'still works\n') + + def test_main_guards_the_streams_and_uses_the_rule(self): + src = inspect.getsource(srv_main.main) + self.assertIn('_guard_console_streams()', src) + self.assertEqual(src.count('print(_console_rule())'), 2) + # the raw pretty character must not come back to the banner + self.assertNotIn('\u2500', src) + + def test_no_console_write_carries_a_non_ascii_literal(self): + """Every print()/stdout/stderr write in the package stays ASCII: a + character the ANSI code page lacks would kill a piped start.bat run + (the guard degrades it to '?', but the traces should stay readable).""" + pkg = pathlib.Path(srv_main.__file__).resolve().parent + offenders = [] + for path in sorted(pkg.glob('*.py')): + tree = ast.parse(path.read_text(encoding='utf-8')) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + f = node.func + is_print = isinstance(f, ast.Name) and f.id == 'print' + is_write = (isinstance(f, ast.Attribute) and f.attr == 'write' + and isinstance(f.value, ast.Attribute) + and f.value.attr in ('stdout', 'stderr')) + if not (is_print or is_write): + continue + for a in ast.walk(node): + if isinstance(a, ast.Constant) and isinstance(a.value, str): + bad = ''.join(c for c in a.value if ord(c) > 127) + if bad: + offenders.append('%s:%d: %r' + % (path.name, node.lineno, bad)) + self.assertEqual(offenders, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_startup_cardless.py b/tests/test_startup_cardless.py index 1e06008..d6aeeb8 100644 --- a/tests/test_startup_cardless.py +++ b/tests/test_startup_cardless.py @@ -24,7 +24,7 @@ from pySim.exceptions import NoCardError, SwMatchError from pysim_simple_server import __main__ as srv_main from pysim_simple_server.server import _LineFilter -CARDLESS_LINE = 'INIT: no card in the reader — server ready; insert a card or press Equip' +CARDLESS_LINE = 'INIT: no card in the reader - server ready; insert a card or press Equip' class LineFilterTest(unittest.TestCase):