diff options
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | examples/messenger.aup | 55 | ||||
| -rw-r--r-- | tests/check.py | 149 |
3 files changed, 216 insertions, 1 deletions
@@ -43,6 +43,19 @@ curl -s 'localhost:8767/views/cards_list?from=0&count=20' --surface build/vega-surface.so -- display --symbols ui.vsym --live # 디스플레이와 HTTP가 한 노드를 쓴다 ``` +`examples/messenger.aup`는 두 노드 사이의 메신저다. 여기서 쓴 줄은 저장되고 상대 노드로 간다. 노드마다 제 키를 갖고, +상대의 키로 봉인하며, 상대만을 이름과 키로 받는다. 그래서 사이의 중계는 읽지도 바꾸지도 못한다(Pure 086, 088). 화면은 노드 +안의 Vega다. + +```sh +AUP_PATH=lib ../aup/build/aup build examples/messenger.aup -o messenger.pmod +# 앨리스의 노드(밥의 노드는 이름과 키를 바꿔 같게). 두 공개키는 각 노드를 --key로 한 번 띄우면 시작 줄에 나온다. +../pure/build/pure-serve --module messenger.pmod --store ~/.alice --key ~/.alice.key --name alice --port 8801 \ + --send said=http://bob.local:8802/inputs/heard --seal said=BOB_KEY --sender bob.said=BOB_KEY \ + --surface build/vega-surface.so -- display --symbols ui.vsym --live +curl -s -X POST -d alice localhost:8801/inputs/rename # 이름(한 번) +``` + 글자와 아이콘은 하나의 바이너리 형식(심볼 파일)으로 그린다. 폰트와 SVG는 빌드할 때 한 번만 해석한다 ([설계 002](docs/design/002-symbol-format.md)). diff --git a/examples/messenger.aup b/examples/messenger.aup new file mode 100644 index 0000000..af513a1 --- /dev/null +++ b/examples/messenger.aup @@ -0,0 +1,55 @@ +# A messenger between two nodes. A line typed here is kept and goes to the +# other node; the other node's lines come here; all stay in order. Each node +# runs it with a key of its own, seals to the other's key, and takes lines +# only from the other, by name and key (pure-serve --key, --send, --seal, +# --sender): what passes between them is sealed end to end, and nothing on +# the way can read or change it. People type on the node's display (Vega in +# the node, pure-serve --surface); programs use the same node over HTTP. +use vega + +record Line { + from: text + body: text +} + +state lines: list of Line +state at: index of lines +state me: text = "me" + +input say = text +input up = key ArrowUp +input down = key ArrowDown +input rename = message { + name: text +} +input heard = message { + from: text + body: text +} +output said = message { + from: text + body: text +} + +on say { + lines.append(Line { from: me, body: say }) + at = lines.last + send said { from: me, body: say } +} +on heard { + lines.append(Line { from: heard.from, body: heard.body }) + at = lines.last +} +on rename { me = rename.name } +on up.down { at = at - 1 } +on down.down { at = at + 1 } + +view title = "{me}: {lines.count} lines" + +# A list: each line's text, who wrote it as its badge, the other's in accent. +view lines_list = each line in lines { + label = line.body + badge = line.from + tone = if line.from == me { vega.normal } else { vega.accent } +} +view lines_list_focus = at.position diff --git a/tests/check.py b/tests/check.py index c0633c3..ad30654 100644 --- a/tests/check.py +++ b/tests/check.py @@ -1243,6 +1243,152 @@ def node_display(vega, serve, module, work): return {'events': len(events), 'lines': len(got)} +def messenger(vega, serve, module, work): + """Two nodes of examples/messenger.aup, Alice's and Bob's, each with a key + of its own, sealing to the other's and taking lines only from the other + (pure-serve --key, --send, --seal, --sender), each showing its app + through Vega's pixels in the node. A line typed on Alice's display + (recorded keys) reaches Bob's node sealed and is drawn on his; Bob's + answer, posted by a program, is drawn on hers. Each last frame is the one + the vega command draws from that node's store, and the relay between the + nodes saw no line's text nor a field's name, only seals made in one + session each and naming the sender's key.""" + import http.client + import select + import shutil + import signal + import struct + import threading + import time + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + face, library = work / 'ui.vsym', vega.parent / 'vega-surface.so' + if not face.exists() or not library.exists(): + return None + nodes = {} # name: (process, port) + seen = [] # (path, headers, body) relayed + + class Relay(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def do_POST(self): + body = self.rfile.read(int(self.headers['Content-Length'])) + to, _, path = self.path[1:].partition('/') + headers = {k: v for k, v in self.headers.items() if k.lower().startswith('x-pure-') or + k.lower() in ('content-type', 'idempotency-key')} + seen.append((self.path, headers, body)) + connection = http.client.HTTPConnection('127.0.0.1', nodes[to][1], timeout=10) + connection.request('POST', '/' + path, body, headers) + answer = connection.getresponse() + data = answer.read() + connection.close() + self.send_response(answer.status) + for k, v in answer.getheaders(): + if k.lower().startswith('x-pure-'): + self.send_header(k, v) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *args): + pass + + def start(name, *extra): + process = subprocess.Popen([str(a) for a in (serve, '--module', module, '--store', work / f'{name}-store', + '--port', 0, '--key', work / f'{name}.key', '--name', name, *extra)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + printed, deadline = '', time.time() + 10 + while '\n' not in printed and time.time() < deadline: + if select.select([process.stdout], [], [], 1)[0]: + printed += os.read(process.stdout.fileno(), 65536).decode() + assert '\n' in printed, 'node did not start' + first, rest = printed.split('\n', 1) + nodes[name] = (process, json.loads(first)['port']) + return json.loads(first)['public_key'], rest + + def stop(name): + process = nodes[name][0] + process.send_signal(signal.SIGTERM) + _, error = process.communicate(timeout=30) + assert process.returncode == 0, error[-800:] + return error + + def call(name, method, path, body=None): + connection = http.client.HTTPConnection('127.0.0.1', nodes[name][1], timeout=10) + connection.request(method, path, body) + answer = connection.getresponse() + data = answer.read() + connection.close() + return answer.status, data.decode() + + def wait_for(condition, seconds=20): + deadline = time.time() + seconds + while not condition() and time.time() < deadline: + time.sleep(0.05) + return condition() + + relay = ThreadingHTTPServer(('127.0.0.1', 0), Relay) + threading.Thread(target=relay.serve_forever, daemon=True).start() + keys = {} + try: + # Each node's key made, its name set (by a program: a message of one text field takes the body), before they meet. + for name in ('alice', 'bob'): + keys[name], _ = start(name) + assert call(name, 'POST', '/inputs/rename', name.encode())[0] == 200 + stop(name) + other = {'alice': 'bob', 'bob': 'alice'} + wiring = {name: ['--send', f'said=http://127.0.0.1:{relay.server_address[1]}/{other[name]}/inputs/heard', + '--seal', f'said={keys[other[name]]}', '--sender', f'{other[name]}.said={keys[other[name]]}', + '--surface', library, '--', 'pixels', '--symbols', face, '--size', 16, '--grid', '40x8', + '--out', work / f'{name}-frame'] for name in ('alice', 'bob')} + start('bob', *wiring['bob']) + codes = {'h': 35, 'e': 18, 'l': 38, 'o': 24, 'b': 48, 'space': 57, 'enter': 28} + events = [] + for name in ('h', 'e', 'l', 'l', 'o', 'space', 'b', 'o', 'b', 'enter'): + events.extend([(1, codes[name], 1), (0, 0, 0), (1, codes[name], 0), (0, 0, 0)]) + recording = work / 'alice-events.bin' + recording.write_bytes(b''.join(struct.pack('llHHi', 0, 0, *event) for event in events)) + _, printed = start('alice', *wiring['alice'], '--input', recording) + process = nodes['alice'][0] + deadline = time.time() + 20 + while '\nframe ' not in '\n' + printed and time.time() < deadline: + if select.select([process.stdout], [], [], 1)[0]: + printed += os.read(process.stdout.fileno(), 65536).decode() + assert 'hello bob' in printed and '\nframe ' in '\n' + printed, printed + assert wait_for(lambda: call('bob', 'GET', '/views/title')[1] == 'bob: 1 lines'), call('bob', 'GET', '/views/title') + assert call('bob', 'POST', '/inputs/say', b'hi alice')[0] == 200 + assert wait_for(lambda: call('alice', 'GET', '/views/title')[1] == 'alice: 2 lines') + drawn = {} + for name in ('alice', 'bob'): + # Its surface draws after each save: the last frame shows the last save. + frames = work / f'{name}-frame' + count = lambda: len(list(work.glob(f'{name}-frame-*.ppm'))) + settled = count() + time.sleep(0.3) + assert count() == settled + drawn[name] = max(work.glob(f'{name}-frame-*.ppm'), key=lambda path: int(path.stem.rsplit('-', 1)[1])) + error = stop(name) + assert b'"returned":0' in error, error[-800:] + for name in ('alice', 'bob'): + copy = work / f'{name}-copy' + shutil.copytree(work / f'{name}-store', copy) + expected = run(vega, 'pixels', module, '--store', copy, '--symbols', face, '--size', 16, '--grid', '40x8', + '--out', work / f'{name}-expected', stdin='show\n') + assert expected.returncode == 0, expected.stderr + assert 'hello bob' in expected.stdout and 'hi alice' in expected.stdout, expected.stdout + assert drawn[name].read_bytes() == (work / f'{name}-expected-1.ppm').read_bytes(), name + finally: + for process, _ in nodes.values(): + if process.poll() is None: + process.kill() + process.wait() + relay.shutdown() + assert seen and all(len(headers['X-Pure-Seal'].split()) == 4 for _, headers, _ in seen), seen[:1] + assert not any(word in body for _, _, body in seen for word in (b'hello', b'hi alice', b'body', b'from')) + sessions = {(headers['X-Pure-Source'], headers['X-Pure-Seal'].split()[1]) for _, headers, _ in seen} + assert len(sessions) == 2, sessions # one session each way + return {'relayed': len(seen), 'lines': 2} + + def width_of(character): return width(character) or 0 @@ -1262,7 +1408,7 @@ def main(): work = Path(directory) env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) modules = {} - for name in ('todo', 'scores', 'today', 'stopwatch', 'board'): + for name in ('todo', 'scores', 'today', 'stopwatch', 'board', 'messenger'): modules[name] = work / f'{name}.pmod' built = run(aup, 'build', ROOT / 'examples' / f'{name}.aup', '-o', modules[name], env=env) assert built.returncode == 0, built.stderr @@ -1289,6 +1435,7 @@ def main(): report['display'] = display(vega, modules['todo'], work) report['live_display'] = live_display(vega, modules['todo'], work) report['node_display'] = node_display(vega, args.serve.resolve(), modules['todo'], work) + report['messenger'] = messenger(vega, args.serve.resolve(), modules['messenger'], work) print(json.dumps(report)) |
