aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md13
-rw-r--r--examples/messenger.aup55
-rw-r--r--tests/check.py149
3 files changed, 216 insertions, 1 deletions
diff --git a/README.md b/README.md
index 3b2ed3c..ee0feb7 100644
--- a/README.md
+++ b/README.md
@@ -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))