Files
Mark-VIII/docs/superpowers/plans/2026-08-11-handshakes-markvii-parity.md
T
2026-08-11 20:24:24 -07:00

30 KiB
Raw Blame History

Handshakes Mark VII Parity Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the Pager WebUI Recon → Handshakes tab (#/recon/handshakes) look and behave like the stock Mark VII handshakes page (BSSID/Client/Source/Type/Captured/Message 14/Beacon Frame table, per-row Download & Delete, settings dialog with location + Delete All).

Architecture: server.py synthesizes the Mark VII {handshakes:[...]} shape from loot filenames + one scoped recon.db correlation read (zero DB reads when the loot dir is empty). The vanilla-JS views.recon_handshakes view is rebuilt to render the Mark VII table with check/X/? glyphs, a success/error flash, and a settings modal. New icons and CSS are added. Dashboard keeps reading the unchanged files array.

Tech Stack: Python 3.11 (Windows dev) / device python3-light; stdlib unittest with mocks; vanilla JS + hand-rolled h() DOM helpers.

Global Constraints

  • DB reads: at most 1 _db_rows call per handshakes request, and only when the loot dir is non-empty. No polling added to the handshakes view.
  • No new dependencies — pure stdlib; reuse existing helpers _db_rows, fmt_mac, hsType, iconBtn, btn, h, PagerAPI, App.
  • Dashboard unchanged: GET /api/pineap/handshakes must keep returning a files array with the current shape ({name,size,mtime}).
  • Router is first-match-wins: the location GET route MUST be registered before GET /api/pineap/handshakes/([^/]+).
  • Tests run per module in their own process (README): & "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v.
  • Copy exact strings from the spec (Mark VII wording for tooltips/empty state, pcapPCAP, 22000Hashcat).


Task 1: Backend — filename parser + handshakes_data() Mark VII shape

Files:

  • Modify: payload/user/general/pager-webui/server.py (handshakes_data() at ~line 1060; add parse_hs_filename, _norm_mac, _hs_db_by_pair, _compose_hs above it)
  • Test: tests/test_recon.py (add HS_RE/parse_hs_filename/handshakes_data tests)

Interfaces:

  • Consumes: LOOT_HS_DIR (module global, str), RECON_DB (module global, str path), _db_rows(db, sql) (existing), os.listdir/os.stat.

  • Produces:

    • parse_hs_filename(name) -> dict | None with keys ts (int|None), ap (colon MAC str), client (colon MAC str), kind ('full'|'partial'|'incomplete'), ext (str).
    • _norm_mac(m) -> str — uppercased, dash→colon, 12-hex→colon form.
    • handshakes_data() -> {'files': [...], 'handshakes': [...]} where each handshake record has keys mac, client, source, type, timestamp, in_db, part_mask, beacon, extension, name, location, file_exists.
  • Step 1: Write the failing tests

Append to tests/test_recon.py (add import shutil to the module imports at the top if not already present):

def make_hs_db():
    db = make_db()
    conn = sqlite3.connect(db)
    conn.execute(
        "INSERT INTO handshake (hash, scan, stahash, aphash, time, beacon, hs1, hs2, hs3, hs4) "
        "VALUES (21, 1, 1, 2, 1786466650, X'BEACON', X'01', X'02', X'03', X'04')")
    conn.commit()
    conn.close()
    return db


class ParseHsFilenameTest(unittest.TestCase):
    def test_parse_full_pcap(self):
        p = server.parse_hs_filename('1786466650_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake.pcap')
        self.assertEqual(p['ts'], 1786466650)
        self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
        self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')
        self.assertEqual(p['kind'], 'full')
        self.assertEqual(p['ext'], 'pcap')

    def test_parse_partial_and_incomplete(self):
        p = server.parse_hs_filename('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake_partial.22000')
        self.assertEqual(p['kind'], 'partial')
        self.assertEqual(p['ext'], '22000')
        p = server.parse_hs_filename('1_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake_incomplete.pcap')
        self.assertEqual(p['kind'], 'incomplete')
        self.assertEqual(p['ext'], 'pcap')

    def test_parse_dash_macs_and_no_ts(self):
        p = server.parse_hs_filename('C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
        self.assertIsNone(p['ts'])
        self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
        self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')

    def test_parse_unrecognized(self):
        self.assertIsNone(server.parse_hs_filename('random.cap'))
        self.assertIsNone(server.parse_hs_filename('notes.txt'))
        self.assertIsNone(server.parse_hs_filename(''))
        self.assertIsNone(server.parse_hs_filename('123_mac1_mac2_handshake'))


class HandshakesDataTest(unittest.TestCase):
    def setUp(self):
        self.db = make_hs_db()
        server.RECON_DB = self.db
        self.dir = tempfile.mkdtemp()
        server.LOOT_HS_DIR = self.dir

    def tearDown(self):
        shutil.rmtree(self.dir)
        os.unlink(self.db)

    def _write(self, name, ts):
        os.utime(os.path.join(self.dir, name), (ts, ts))

    def test_empty_dir_skips_db(self):
        with mock.patch.object(server, '_db_rows', side_effect=AssertionError('db should not be touched')):
            data = server.handshakes_data()
        self.assertEqual(data, {'files': [], 'handshakes': []})

    def test_correlation_composes_full_record(self):
        self._write('1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap', 1786466650)
        data = server.handshakes_data()
        self.assertEqual(len(data['files']), 1)
        hs = data['handshakes'][0]
        self.assertEqual(hs['mac'], 'C8:9E:43:64:80:80')
        self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41')
        self.assertEqual(hs['source'], 'Recon')
        self.assertEqual(hs['type'], 'full')
        self.assertEqual(hs['extension'], 'pcap')
        self.assertEqual(hs['timestamp'], 1786466650)
        self.assertTrue(hs['in_db'])
        self.assertEqual(hs['part_mask'], 15)
        self.assertTrue(hs['beacon'])
        self.assertEqual(hs['name'], '1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
        self.assertTrue(hs['file_exists'])
        self.assertEqual(hs['location'], os.path.join(server.LOOT_HS_DIR, hs['name']))

    def test_file_not_in_db_has_question_mark_fields(self):
        self._write('1786467000_AA-BB-CC-DD-EE-FF_00-11-22-33-44-55_handshake.pcap', 1786467000)
        hs = server.handshakes_data()['handshakes'][0]
        self.assertFalse(hs['in_db'])
        self.assertEqual(hs['part_mask'], 0)
        self.assertFalse(hs['beacon'])
        self.assertEqual(hs['timestamp'], 1786467000)

    def test_unparseable_file_still_listed_with_placeholders(self):
        self._write('random.cap', 1786467005)
        hs = server.handshakes_data()['handshakes'][0]
        self.assertEqual(hs['mac'], '--')
        self.assertEqual(hs['client'], '--')
        self.assertFalse(hs['in_db'])
        self.assertEqual(hs['extension'], 'cap')

tests/test_recon.py already imports os, sqlite3, sys, tempfile, unittest, mock and import server; add import shutil to the top-of-module imports. re is not needed.

  • Step 2: Run tests to verify they fail

Run:

$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -m unittest tests.test_recon -v

Expected: AttributeError: module 'server' has no attribute 'parse_hs_filename' (and failures on handshakes_data shape).

  • Step 3: Implement parser + data composition

In server.py, replace the existing handshakes_data() (lines ~10601074) with:

HS_FILENAME_RE = re.compile(
    r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
    r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
    r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$')


def parse_hs_filename(name):
    m = HS_FILENAME_RE.match(name or '')
    if not m:
        return None
    ts, ap, client, kind, ext = m.groups()
    return {'ts': int(ts) if ts else None,
            'ap': ap.replace('-', ':'),
            'client': client.replace('-', ':'),
            'kind': kind or 'full',
            'ext': ext}


def _norm_mac(m):
    m = (m or '').strip().upper().replace('-', ':')
    if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m):
        m = ':'.join(m[i:i + 2] for i in range(0, 12, 2))
    return m


def _hs_db_by_pair(min_ts):
    rows = _db_rows(RECON_DB,
        'SELECT h.time, '
        '(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, '
        '(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, '
        '(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, '
        '(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, '
        '(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, '
        'w1.mac AS ap, w2.mac AS sta '
        'FROM handshake h '
        'JOIN wifi_device w1 ON w1.hash = h.aphash '
        'JOIN wifi_device w2 ON w2.hash = h.stahash '
        'WHERE h.time >= %d ORDER BY h.time' % min_ts)
    db = {}
    for r in rows:
        db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = {
            'time': r.get('time'),
            'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0)
                       | (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0),
            'beacon': bool(r.get('beacon')),
        }
    return db


def _compose_hs(name, size, mtime, part, db):
    base = {'source': 'Recon', 'name': name, 'size': size,
            'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True}
    if part is None:
        ext = name.rsplit('.', 1)[-1] if '.' in name else ''
        base.update({'mac': '--', 'client': '--', 'type': 'full',
                     'timestamp': mtime, 'in_db': False, 'part_mask': 0,
                     'beacon': False, 'extension': ext})
        return base
    rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client'])))
    base.update({
        'mac': part['ap'], 'client': part['client'], 'type': part['kind'],
        'timestamp': (rec or {}).get('time') or part['ts'] or mtime,
        'in_db': rec is not None,
        'part_mask': (rec or {}).get('part_mask', 0),
        'beacon': bool((rec or {}).get('beacon', False)),
        'extension': part['ext']})
    return base


def handshakes_data():
    files = []
    parsed = []
    min_ts = None
    try:
        names = sorted(os.listdir(LOOT_HS_DIR))
    except OSError:
        names = []
    for name in names:
        p = os.path.join(LOOT_HS_DIR, name)
        try:
            if not os.path.isfile(p) or name.startswith('.'):
                continue
            st = os.stat(p)
        except OSError:
            continue
        mtime = int(st.st_mtime)
        files.append({'name': name, 'size': st.st_size, 'mtime': mtime})
        part = parse_hs_filename(name)
        if part is not None:
            ts = part['ts'] if part['ts'] is not None else mtime
            part['ts'] = ts
            if min_ts is None or ts < min_ts:
                min_ts = ts
        parsed.append((name, st.st_size, mtime, part))
    handshakes = []
    db = _hs_db_by_pair(min_ts) if parsed else {}
    for name, size, mtime, part in parsed:
        handshakes.append(_compose_hs(name, size, mtime, part, db))
    return {'files': files, 'handshakes': handshakes}
  • Step 4: Run tests to verify they pass

Run:

$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -m unittest tests.test_recon -v

Expected: all tests pass, including the pre-existing HandshakeFileTest and ReconDataTest cases.

  • Step 5: Commit
git add tests/test_recon.py payload/user/general/pager-webui/server.py
git commit -m "feat: handshakes endpoint returns Mark VII records from loot files + recon.db"

Task 2: Backend — location + delete-all routes

Files:

  • Modify: payload/user/general/pager-webui/server.py (add h_handshakes_location, h_handshakes_delete_all; reorder/add route registrations at ~lines 15671569)
  • Test: tests/test_recon.py

Interfaces:

  • Consumes: LOOT_HS_DIR, handshakes_data() (Task 1).

  • Produces:

    • h_handshakes_location(ctx) -> (200, {'location': str})
    • h_handshakes_delete_all(ctx) -> (200, handshakes_data())
    • Router registrations: GET /api/pineap/handshakes/location (BEFORE the ([^/]+) file route), DELETE /api/pineap/handshakes/all.
  • Step 1: Write the failing tests

Append to tests/test_recon.py:

class HandshakeRoutesTest(unittest.TestCase):
    def setUp(self):
        self.dir = tempfile.mkdtemp()
        server.LOOT_HS_DIR = self.dir

    def tearDown(self):
        shutil.rmtree(self.dir)

    def _write(self, name, data=b'data'):
        with open(os.path.join(self.dir, name), 'wb') as f:
            f.write(data)

    def test_location_returns_loot_dir(self):
        status, data = server.h_handshakes_location(type('C', (), {'args': ()})())
        self.assertEqual(status, 200)
        self.assertEqual(data['location'], self.dir)

    def test_location_route_precedes_file_download(self):
        h, args = server.ROUTER.dispatch('GET', '/api/pineap/handshakes/location')
        self.assertIs(h, server.h_handshakes_location)

    def test_delete_all_removes_files(self):
        self._write('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
        self._write('2_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.22000')
        status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
        self.assertEqual(status, 200)
        self.assertEqual(data['files'], [])
        self.assertEqual(data['handshakes'], [])
        self.assertEqual(os.listdir(self.dir), [])

    def test_delete_all_empty_dir_is_ok(self):
        status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
        self.assertEqual(status, 200)
        self.assertEqual(data['files'], [])
  • Step 2: Run tests to verify they fail

Run:

$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -m unittest tests.test_recon -v

Expected: AttributeError: module 'server' has no attribute 'h_handshakes_location' etc.

  • Step 3: Implement the handlers + routes

In server.py, after h_handshakes_delete (line ~1100), add:

def h_handshakes_location(ctx):
    return 200, {'location': LOOT_HS_DIR}


def h_handshakes_delete_all(ctx):
    try:
        names = os.listdir(LOOT_HS_DIR)
    except OSError:
        names = []
    for name in names:
        p = os.path.join(LOOT_HS_DIR, name)
        try:
            if os.path.isfile(p) and not name.startswith('.'):
                os.remove(p)
        except OSError:
            continue
    return 200, handshakes_data()

Replace the route block (lines ~15671569) with:

ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file)
ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete)

(location MUST stay above the ([^/]+) GET route — the Router is first-match-wins.)

  • Step 4: Run tests to verify they pass

Run:

$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -m unittest tests.test_recon -v

Expected: all pass.

  • Step 5: Commit
git add tests/test_recon.py payload/user/general/pager-webui/server.py
git commit -m "feat: handshakes location + delete-all endpoints"

Task 3: Frontend — glyph icons + CSS

Files:

  • Modify: payload/user/general/pager-webui/www/js/icons.js
  • Modify: payload/user/general/pager-webui/www/css/app.css

Interfaces:

  • Produces: PineappleIcons.check, PineappleIcons.close, PineappleIcons.question_mark (inline Material SVG strings); CSS classes .hs-cell-center, .hs-ok, .hs-bad, .hs-na, .hs-actions, .hs-warn, .hs-flash, .hs-flash-ok, .hs-flash-error, .modal-overlay, .modal, .modal-title, .modal-body, .modal-actions, .hs-settings-row, .hs-settings-label, .hs-settings-value.

  • Consumed by: Task 4 view.

  • Step 1: Add the three icons

In www/js/icons.js, add inside the window.PineappleIcons = { ... } object (keep alphabetical-ish ordering; the file is a plain object):

  check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
  close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
  question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
  • Step 2: Add CSS

Append to www/css/app.css (after the existing recon rules):

/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
.hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; }
.hs-ok { color: #7cb342; }
.hs-bad { color: #d32f2f; }
.hs-na { color: #9e9e9e; }
.hs-actions { display: inline-flex; align-items: center; gap: 4px; }
.hs-warn { color: #d32f2f; }
.hs-flash { font-size: 12px; margin-left: 8px; }
.hs-flash-ok { color: #7cb342; }
.hs-flash-error { color: #d32f2f; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; }
.modal-title { font-size: 20px; margin-bottom: 16px; }
.modal-body { display: flex; flex-direction: column; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
.hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.hs-settings-label { color: var(--muted); }
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
html.dark .modal { background: #303030; }
  • Step 3: Verify the file parses

There is no node on the dev box and no JS test harness, so do a brace/paren balance sanity check with Python instead of a real parse. From the repo root, run:

$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -c "s=open(r'payload/user/general/pager-webui/www/js/icons.js',encoding='utf-8').read(); assert s.count('{')==s.count('}') and s.count('(')==s.count(')'), 'unbalanced'; print('icons.js balanced OK')"

Expected: prints icons.js balanced OK. (CSS has no build step — the Task 5 browser pass covers it, and the icons resolve in the browser console there.)

  • Step 4: Commit
git add payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/css/app.css
git commit -m "feat: handshake glyph icons and modal/flash CSS"

Task 4: Frontend — rebuild views.recon_handshakes + settings dialog

Files:

  • Modify: payload/user/general/pager-webui/www/js/views.js (replace hsType/views.recon_handshakes, lines ~904952)

Interfaces:

  • Consumes: RECON_TABS, h, tabBar, iconBtn, btn, fmtTime, hsType, PagerAPI, App.apiBase, App.toast, PineappleIcons.* (all existing); backend endpoints from Tasks 12.

  • Produces: rewritten views.recon_handshakes (no signature change — (root) => ({destroy})).

  • Step 1: Replace hsType and views.recon_handshakes

Replace the hsType helper (lines ~904909) and the whole views.recon_handshakes definition (lines ~911952) in www/js/views.js with:

views.recon_handshakes = (root) => {
  root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
  tabBar(root, RECON_TABS, '#/recon/handshakes');
  const box = h('div', { class: 'section recon-handshakes-card' });
  root.appendChild(box);

  let flashTimer = null;
  function flash(ok, msg) {
    const head = box.querySelector('.recon-table-head');
    if (!head) return;
    const old = head.querySelector('.hs-flash');
    if (old) old.remove();
    const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
    head.appendChild(el);
    clearTimeout(flashTimer);
    flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
  }

  function hsIconBtn(name, title, cls, onclk) {
    const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk });
    b.innerHTML = PineappleIcons[name] || '';
    return b;
  }

  function textCell(v) {
    return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) });
  }

  function naGlyph() {
    return h('td', { class: 'mat-cell hs-cell-center' },
      h('span', { class: 'hs-na',
        title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted." },
        PineappleIcons.question_mark));
  }

  function boolGlyph(v) {
    return h('td', { class: 'mat-cell hs-cell-center' },
      h('span', { class: v ? 'hs-ok' : 'hs-bad' }, v ? PineappleIcons.check : PineappleIcons.close));
  }

  function msgCell(inDb, present) {
    return inDb ? boolGlyph(present) : naGlyph();
  }

  function load(done) {
    PagerAPI.get('/api/pineap/handshakes').then((r) => {
      box.innerHTML = '';
      const head = h('div', { class: 'recon-table-head' },
        h('h2', { text: 'Captured WPA Handshakes' }),
        h('span', { class: 'toolbar-spacer' }),
        iconBtn('settings', 'Handshakes settings', openSettings));
      box.appendChild(head);
      box.appendChild(h('div', { class: 'row' },
        h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })),
        h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))),
        h('div', {}, btn('Refresh', () => load(), 'ghost'))));

      const hs = r.data.handshakes || [];
      if (!hs.length) {
        box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' }));
        if (done) done();
        return;
      }
      const t = h('table', { class: 'tbl' });
      const thead = h('thead');
      const th = h('tr');
      ['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2',
       'Message 3', 'Message 4', 'Beacon Frame', '']
        .forEach((c) => th.appendChild(h('th', { text: c })));
      thead.appendChild(th);
      t.appendChild(thead);
      const tb = h('tbody');
      hs.forEach((f) => {
        const trr = h('tr');
        trr.appendChild(textCell(f.mac));
        trr.appendChild(textCell(f.client));
        trr.appendChild(textCell(f.source));
        trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name)));
        trr.appendChild(textCell(fmtTime(f.timestamp)));
        [1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0)));
        trr.appendChild(msgCell(f.in_db, !!f.beacon));
        const act = h('td', { class: 'mat-cell' },
          h('span', { class: 'hs-actions' },
            hsIconBtn('file_download', 'Download', '', () => {
              window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name);
            }),
            hsIconBtn('delete', 'Delete', 'hs-warn', () => {
              PagerAPI.del('/api/pineap/handshakes', { name: f.name })
                .then(() => load(() => flash(true, 'Deleted ' + f.name)))
                .catch(() => flash(false, 'Failed to delete ' + f.name));
            })));
        trr.appendChild(act);
        tb.appendChild(trr);
      });
      t.appendChild(tb);
      box.appendChild(t);
      if (done) done();
    }).catch(() => flash(false, 'Failed to load handshakes'));
  }

  function openSettings() {
    PagerAPI.get('/api/pineap/handshakes/location').then((r) => {
      const loc = (r.data || {}).location || '--';
      const overlay = h('div', { class: 'modal-overlay' });
      function close() { overlay.remove(); }
      overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
      const modal = h('div', { class: 'modal' },
        h('div', { class: 'modal-title', text: 'Handshake Settings' }),
        h('div', { class: 'modal-body' },
          h('div', { class: 'hs-settings-row' },
            h('span', { class: 'hs-settings-label', text: 'Handshake Location' }),
            h('span', { class: 'hs-settings-value', text: loc })),
          btn('Delete All Handshakes', () => {
            PagerAPI.del('/api/pineap/handshakes/all')
              .then(() => { close(); load(() => flash(true, 'All handshakes deleted')); })
              .catch(() => { close(); flash(false, 'Failed to delete all handshakes'); });
          }, 'danger')),
        h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost')));
      overlay.appendChild(modal);
      document.body.appendChild(overlay);
    }).catch(() => App.toast('Failed to load handshake settings', 'error'));
  }

  load();
  return { destroy: () => {} };
};

Notes:

  • The old hsType(name) helper is still referenced (it maps f.namePCAP/Hashcat/Unknown), so leave it in place.

  • msgCell renders a check/X/? glyph for Message 14 and Beacon Frame exactly like the Mark VII.

  • Delete is deliberately no-confirm to match the Mark VII; the flash + reload give feedback.

  • Step 2: Verify the view wiring

Serve locally and log in:

& .\scripts\dev.ps1 -Tunnel

Browse http://127.0.0.1:8000/#/recon/handshakes and confirm the page renders without console errors (empty state "No Handshakes Available" is expected with no captured handshakes). Check the settings gear opens the dialog showing the location and the Delete All button.

  • Step 3: Commit
git add payload/user/general/pager-webui/www/js/views.js
git commit -m "feat: Mark VII handshakes table with per-row download/delete and settings dialog"

Task 5: Deploy + on-device verification

Files:

  • None (deployment + manual smoke pass)

Interfaces:

  • Consumes: all tasks 14.

  • Step 1: Deploy to the Pager

Run (password auth via sshpass, per README):

& .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
  • Step 2: Restart the webui service
sshpass -p "<PAGER_PASSWORD>" ssh -o StrictHostKeyChecking=no root@172.16.52.1 "/etc/init.d/pagerwebui restart"
  • Step 3: Smoke test the API

Log in and check the new shape + routes:

$tok = (curl.exe -s -X POST http://172.16.52.1:8080/api/login -H "Content-Type: application/json" -d "{\"username\":\"root\",\"password\":\"<PAGER_PASSWORD>\"}" | ConvertFrom-Json).token
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes/location

Expected: handshakes response has both files and handshakes keys; location returns {"location":"/root/loot/handshakes"}. (Use the AUTH_<serverid> cookie name the login actually sets — it is AUTH_ + the returned serverid, e.g. AUTH_001337AEE050.)

  • Step 4: Browser verification checklist

Browse http://172.16.52.1:8080/#/recon/handshakes and verify:

  • Table headers exactly: BSSID, Client, Source, Type, Captured, Message 14, Beacon Frame, action.

  • Zip-all / Archive / Refresh row still present and working.

  • With no captures: "No Handshakes Available".

  • With a capture (or a manually placed file 1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap in /root/loot/handshakes/ for testing): BSSID/Client/Source/Type/Captured render; M14/Beacon show ✓/✗ or ?; download and delete work; delete flashes green; dark theme (html.dark) looks right.

  • Gear opens dialog with location + Delete All; Delete All empties the dir and reloads.

  • Dashboard (handshakes count + "Captured WPA Handshakes" table) still renders from files.

  • Step 5: Clean up test file + commit any fixes

Remove any manually placed test file from /root/loot/handshakes/. If verification found defects, fix them in a new task-style cycle (test → fix → re-deploy), then commit the fixes.

git add -A
git commit -m "fix: on-device verification adjustments"

(Only if there were fixes; otherwise skip.)


Self-Review

Spec coverage:

  • §4.1 handshakes_data() shape + empty-dir skip + single batched read → Task 1.
  • §4.2 filename parser + fallback → Task 1 (parser + _compose_hs fallback branch).
  • §4.3 record composition (incl. name field) → Task 1.
  • §4.4 location + delete-all routes + ordering → Task 2.
  • §5.1 view rewrite (table, glyphs, download/delete, flash, empty state) → Task 4.
  • §5.2 settings dialog → Task 4.
  • §5.3 icons → Task 3.
  • §5.4 CSS → Task 3.
  • §6 compute mitigation → Task 1 (empty-dir skip), Task 1/4 (no polling).
  • §8 testing → Tasks 12, §5 smoke → Task 5.

Placeholder scan: No TBD/TODO/“similar to” — every code step contains full code.

Type consistency: parse_hs_filename, _norm_mac, _hs_db_by_pair, _compose_hs, handshakes_data, h_handshakes_location, h_handshakes_delete_all — names/return shapes consistent between Task 1, Task 2, and the view in Task 4 (f.name, f.mac, f.client, f.type, f.timestamp, f.in_db, f.part_mask, f.beacon). hsType(f.name) reuses the existing helper. AUTH_<serverid> cookie note in Task 5 matches server.py h_login.