ONNOlympus News NetworkCurated WoW: Forever news for the Olympus army Sign in
Desktop app55,255 members

ONN Companion

The Olympus overlay for WoW: Forever. Press Ctrl+Shift+O and a see-through panel slides in over the game with your guild events, supply runs and guild boards. Claim a supply run or answer an event sign-up without alt-tabbing. It also sends the Olympus Guild addon's census so the Army page stays current. Sign in with your Archway ID; there are no keys to copy.

Version 3.0.0-alpha.7 · built Sep 30, 2026

Windows 10 / 11Installer (.zip) · 2.4 MB636079f7d92b7b55…Linux: Ubuntu, Debian, Mint, Pop!_OSPackage (.deb) · 3.9 MB205903b1fa174305…Linux: any distroAppImage · 79.0 MB6053d747e309ea85…
macOSDisk image (.dmg) · coming soon

Install

Windows: open the downloaded .zip and double-click the installer inside (no admin rights needed). The app isn't code-signed yet, so Windows may show "Windows protected your PC": click More info, then Run anyway. Prefer the bare installer? Download the .exe.

Linux (.deb): sudo apt install ./onn-companion_*.deb, then open ONN Companion from your apps.
Linux (AppImage): make it executable (chmod +x ONN-Companion-*.AppImage) and run it.

The first time, the panel asks you to sign in. The approve page opens in your web browser: sign in with your Archway ID, check the code matches the one in the panel, and click Approve. The panel signs in by itself.

Already using the older ONN Companion (2.x)? Quit it first from its tray icon (right-click, Quit) so the two don't both send your census.

What it does

The overlay opens with Ctrl+Shift+O (or Alt+Shift+O if something else already uses it) or by clicking the ONN shield in the tray. Home shows quick tiles and what's coming up; Supply, Events, Guilds and News tabs go deeper. Cards open in place for details. The Army page, Find Players, requesting supplies and replying on your guild board open right inside the panel; links to other sites open in your browser. Press Esc or click away to close it, or pin it open.

Notices: a note pops up when one of your guild events is about to start, someone asks to join a guild space you run, or your census uploads are approved.

Census uploads: WoW addons can't reach the internet, so the Olympus Guild addon's data only leaves the game through its saved file, WTF/Account/<ACCOUNT>/SavedVariables/Olympus.lua, which WoW writes when you log out, /reload or exit. The app finds it in normal installs and, on Linux, inside Wine, Lutris, Bottles and Steam (Proton) setups, and sends it when it changes. The site keeps only the guild census and, for officers, tabard inspections; everything else in the file is discarded on arrival. An ONN admin approves new uploaders so the census stays accurate; guild masters and officers are approved straight away.

Updates: the app checks for a new version by itself and, on Windows and the AppImage, downloads it in the background and installs it the next time you close the panel. It reopens on its own. To update right away, click Check for updates at the bottom of the panel (or in the tray menu), then Restart to update. The .deb version tells you when a new one is out; download it here. Updates are signed, so the app only installs builds that came from ONN.

Your sign-in is kept in your system's own password store (Windows Credential Manager, macOS Keychain, or your Linux keyring). The app never sees your Archway ID password. Sign it out from the tray menu or from your profile.

Good to know: WoW needs to be in Windowed (Fullscreen) or windowed mode for the overlay to show on top. On Linux, some Wayland desktops (GNOME especially) don't let apps stay on top or use global hotkeys; the tray icon still opens the panel.

Source available

All of ONN Companion's code is here, so you can see exactly what it does before you run it. It's all rights reserved: the code is published for review only. You're welcome to read it, check it and run the official download, but not to fork, copy, modify or redistribute it. That keeps one trusted version that only talks to olympusnewsnetwork.com. ONN Companion is © 2026 Archway Point.

It's built with Tauri (Rust plus your system's own web view). Found a bug or have an idea? Tell an ONN admin in the Olympus Discord.

LICENSE.txt36 lines
ONN Companion - License
Copyright (c) 2026 Archway Point (archwaypoint.com). All rights reserved.
ONN Companion is made for Olympus News Network (olympusnewsnetwork.com).

ONN Companion is proprietary software. Its source code is published only so anyone can read and
check exactly what it does before running it. Publishing the code does not make it open source and
grants no rights beyond those listed below. You may look, but you may not fork.

You MAY:
  1. Read, review and audit the source code.
  2. Install and run unmodified copies downloaded from olympusnewsnetwork.com, for your own
     personal, non-commercial use.
  3. Share links to https://olympusnewsnetwork.com/companion so others can download it there.
  4. Report bugs or suggest changes to the ONN admins (for example in the Olympus Discord).

You MAY NOT, without written permission from Archway Point:
  1. Copy, fork, republish or redistribute the code or the download, in whole or in part,
     including on GitHub or other code-hosting or file-sharing sites.
  2. Modify it and run, share or publish the modified version, or make derivative works from it.
  3. Use it, or any part of it, in another app, service or product, or for commercial purposes.
  4. Remove or change this license or the copyright notice.
  5. Use it to send data to any server other than olympusnewsnetwork.com.

Suggestions you send may be used to improve ONN Companion without any obligation to you.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL ARCHWAY POINT BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.

Any permission not expressly granted here is reserved. Breaking these terms ends your
permission to use the software.

World of Warcraft is a trademark of Blizzard Entertainment, Inc. ONN Companion is a fan-made
tool and is not affiliated with or endorsed by Blizzard Entertainment.
README.md40 lines
# ONN Companion 3 (cross-platform rebuild)

Copyright (c) 2026 Archway Point. All rights reserved. See LICENSE.txt.
Published for review at https://olympusnewsnetwork.com/companion. You may read it, but not fork, copy, modify or redistribute it (see LICENSE.txt).

One app for **Windows, Linux and macOS**, built with Tauri 2 (Rust + the system's own web view:
WebView2 on Windows, WebKitGTK on Linux, WebKit on macOS).

## What it does
- **Overlay:** a see-through panel that slides in from the right edge, always on top. Opens with
  Ctrl+Shift+O (falls back to Alt+Shift+O, Ctrl+Alt+O, Ctrl+Shift+F10 if taken; set `"hotkey"` in
  settings.json) or by clicking the tray icon. Esc or clicking away closes it; the pin keeps it open.
- **Home / Supply / Events / Guilds / News** tabs from `/api/app/feed`; claim supply runs, answer
  event sign-ups, mark requests received, all without leaving the panel. Cards expand in place.
- **ONN pages inside the overlay** (Army, Find Players, request supplies, replies): the panel
  navigates to the page with a small Back / Home / close bar. Other sites open in your browser.
- **Sign in with Archway ID** via the device link (`/api/app/link/*`): the approve page opens in
  your normal web browser and the panel shows the code, then signs in by itself.
  The token lives in the OS secure store (Windows Credential Manager, macOS Keychain, Linux Secret
  Service), with a user-only file as fallback.
- **Census uploads:** finds `WTF/Account/*/SavedVariables/Olympus.lua` in normal installs and, on
  Linux, in Wine, Lutris, Bottles and Steam/Proton prefixes. Sends it when WoW saves it.
- **Notices** (event starting soon, join requests, approvals) as system notifications, or inside
  the panel while it's open.
- Starts with the computer (hidden in the tray), one copy at a time.

## Build
```
cd src-tauri
cargo tauri build --bundles deb,appimage      # Linux
cargo tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis   # Windows, from Linux
cargo tauri build --bundles dmg               # macOS (on a Mac)
```
Linux build needs: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev.

## Not done yet
- Self-updating (Tauri updater with a signing key; needs an update feed on the site).
- Code signing for Windows (Azure Artifact Signing or an OV certificate) and macOS (Apple Developer ID).
- Linux on Wayland: GNOME may refuse always-on-top, positioning and global hotkeys. The tray icon still works.
src/index.html29 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ONN Companion</title>
<!-- Copyright (c) 2026 Archway Point. All rights reserved. -->
<link rel="stylesheet" href="overlay.css">
</head>
<body>
<div class="panel" id="panel">
  <header class="head">
    <img class="crest" src="crest.png" alt="">
    <div class="titles"><b id="hTitle">ONN</b><span id="hSub">Olympus News Network</span></div>
    <button class="ib" id="gear" title="Settings" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button>
    <button class="ib" id="pin" title="Keep open" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M9 3h6l-1 6 3 3v2H7v-2l3-3zM12 14v7"/></svg></button>
    <button class="ib" id="close" title="Close (Esc)"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
  </header>
  <nav class="tabs" id="tabs">
    <button data-tab="home" class="on">Home</button><button data-tab="supply">Supply</button><button data-tab="events">Events</button><button data-tab="guilds">Guilds</button><button data-tab="news">News</button>
  </nav>
  <main class="content" id="content"><p class="empty">Loading...</p></main>
  <div class="upd" id="upd" hidden></div>
  <footer class="foot"><span id="foot"></span><button class="lnk" data-do="checkupdate" id="updBtn">Check for updates</button></footer>
</div>
<script src="overlay.js"></script>
</body>
</html>
src/overlay.css71 lines
/* ONN Companion overlay. Copyright (c) 2026 Archway Point. All rights reserved. */
:root { --panel: rgba(12, 19, 34, .95); --line: #2a3a5c; --card: #141e33; --card2: #1a2742; --text: #ece8dc; --muted: #97a0b5; --gold: #eadfb8; --gold2: #fbf3d6; --on: #0b1633; --tile: #172238; --good: #1d4d31; --bad: #4a1820; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; font: 13px/1.4 "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Noto Sans", Ubuntu, sans-serif; color: var(--text); -webkit-user-select: none; user-select: none; }
button { font: inherit; color: inherit; border: 0; background: none; cursor: pointer; }
.panel { position: absolute; inset: 6px 6px 6px 10px; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 6px 26px rgba(0, 0, 0, .55); overflow: hidden; animation: slide .17s ease-out; }
@keyframes slide { from { transform: translateX(22px); } to { transform: none; } }
body.left .panel { inset: 6px 10px 6px 6px; animation-name: slide-l; }
@keyframes slide-l { from { transform: translateX(-22px); } to { transform: none; } }
.head { display: flex; align-items: center; gap: 9px; padding: 12px 10px 6px 14px; }
.crest { width: 26px; height: 26px; }
.titles { flex: 1; min-width: 0; display: grid; }
.titles b { font-size: 15px; font-weight: 600; color: var(--gold2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.titles span { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ib { width: 30px; height: 28px; border-radius: 6px; display: grid; place-items: center; color: var(--muted); }
.ib:hover { background: #22304f; color: var(--text); }
.ib svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ib[aria-pressed="true"] { color: var(--gold); }
.tabs { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; padding: 2px 10px 8px; }
.tabs button { padding: 7px 0; border-radius: 8px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
.tabs button:hover { background: #1a2540; }
.tabs button.on { background: #1d2a47; color: var(--gold2); }
.content { flex: 1; overflow-y: auto; padding: 0 12px 10px; scrollbar-width: none; }
.content::-webkit-scrollbar { display: none; }
.foot { display: flex; align-items: center; gap: 10px; border-top: 1px solid var(--line); padding: 8px 14px 10px; font-size: 11.5px; color: var(--muted); }
.foot > span { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lnk { flex: none; font-size: 11.5px; color: var(--gold); padding: 0; }
.lnk:hover { color: var(--gold2); text-decoration: underline; }
.lnk:disabled { color: var(--muted); cursor: default; text-decoration: none; }
.upd { display: flex; align-items: center; gap: 10px; margin: 0 10px 8px; padding: 8px 12px; border-radius: 10px; background: var(--card2); border: 1px solid var(--line); font-size: 12.5px; }
.upd[hidden] { display: none; }
.upd span { flex: 1; min-width: 0; }
.sh { font-size: 12px; font-weight: 600; color: var(--muted); margin: 14px 2px 6px; }
.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 4px; }
.tile { text-align: left; background: var(--tile); border-radius: 8px; padding: 9px 10px; min-height: 72px; display: grid; align-content: start; }
.tile:hover { background: #1d2b47; }
.tile.on { background: var(--gold); color: var(--on); }
.tile.on:hover { background: var(--gold2); }
.tile svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }
.tv { font-size: 20px; font-weight: 600; margin-top: 5px; line-height: 1.1; }
.tl { font-size: 11.5px; opacity: .8; }
.card { display: block; width: 100%; text-align: left; background: var(--card); border-radius: 8px; padding: 10px 12px; margin-bottom: 6px; }
.card:hover, .card.open { background: var(--card2); }
.card.hot { box-shadow: inset 3px 0 0 var(--gold); }
.ch { display: flex; gap: 8px; font-size: 11.5px; color: var(--muted); }
.ch span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.hot .ch span:first-child { color: var(--gold); }
.ct { font-size: 14px; font-weight: 600; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cd { font-size: 12.5px; color: var(--muted); margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card.open .ct, .card.open .cd { -webkit-line-clamp: unset; display: block; }
.more { font-size: 13px; margin-top: 8px; white-space: pre-wrap; user-select: text; -webkit-user-select: text; }
.acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pill { background: #22304f; border-radius: 999px; padding: 4px 11px 5px; font-size: 12px; font-weight: 600; }
.pill:hover { background: #2e3f66; }
.pill.p, .pill.on { background: var(--gold); color: var(--on); }
.pill.p:hover, .pill.on:hover { background: var(--gold2); }
.flash { border-radius: 8px; padding: 8px 12px; margin: 4px 0; font-weight: 600; font-size: 12.5px; background: var(--good); }
.flash.bad { background: var(--bad); }
.empty { color: var(--muted); font-size: 12.5px; margin: 6px 2px; }
.signin { margin: 30px 4px 0; }
.signin h2 { font-size: 18px; margin: 0 0 6px; font-weight: 600; }
.signin p { color: var(--muted); margin: 0 0 14px; }
.q0 { color: #9d9d9d; } .q2 { color: #1eff00; } .q3 { color: #4da3ff; } .q4 { color: #c77dff; } .q5 { color: #ff8000; }
.signin .code { font: 700 22px ui-monospace, Consolas, monospace; letter-spacing: .14em; color: var(--gold2); background: #1a2540; border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; display: inline-block; margin: 0 0 12px; user-select: text; -webkit-user-select: text; }

.set { padding: 4px 2px 10px; }
.set h2 { font-size: 15px; margin: 4px 0 2px; color: var(--gold2); }
.set .sh { margin-top: 14px; }
.set .muted { color: var(--muted); font-size: 12px; margin: 2px 0 8px; }
.set .opts { display: flex; flex-wrap: wrap; gap: 6px; }
src/overlay.js206 lines
// ONN Companion overlay screens. Copyright (c) 2026 Archway Point. All rights reserved.
// Draws the panel from /api/app/feed (fetched by the app, which holds the sign-in) and sends
// supply claims and event answers back. ONN pages open inside the panel via open_page.
(() => {
  const T = window.__TAURI__;
  const invoke = (cmd, args) => T.core.invoke(cmd, args);
  const listen = (ev, fn) => T.event.listen(ev, fn);
  const $ = (id) => document.getElementById(id);
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const ago = (iso) => { if (!iso) return ''; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`; };
  const soon = (iso) => { const m = (Date.parse(iso) - Date.now()) / 60e3; return m < 0 ? 'now' : m < 60 ? `in ${Math.round(m)}m` : m < 2160 ? `in ${Math.round(m / 60)}h` : new Date(iso).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); };
  const when = (iso) => new Date(iso).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
  const n = (v) => Number(v || 0).toLocaleString();
  const ICON = {
    box: '<path d="M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5z"/><path d="M3.5 7.5 12 12l8.5-4.5M12 12v9"/>',
    mail: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m4 7 8 6 8-6"/>',
    cal: '<rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/>',
    dot: '<circle cx="12" cy="12" r="3.5"/><circle cx="12" cy="12" r="8"/>',
    users: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="17" cy="9.5" r="2.4"/><path d="M15.8 14.6c2.3.2 4 1.8 4.6 4.9"/>',
    plus: '<path d="M12 5v14M5 12h14"/>',
  };
  const svg = (k) => `<svg viewBox="0 0 24 24">${ICON[k]}</svg>`;

  let info = {}, feed = null, tab = 'home', expanded = '', flash = null, pinned = false, scr = null, lastTab = 'home';

  const say = (text, bad) => { flash = { text, bad, until: Date.now() + 6000 }; render(true); };
  async function refreshInfo() { try { info = await invoke('app_info'); } catch {} drawUpdate(); }
  // Update strip above the footer, and the "Check for updates" link.
  function drawUpdate() {
    const u = info.update || {}, bar = $('upd'), btn = $('updBtn');
    let html = '';
    if (u.state === 'ready') html = `<span>Version ${esc(u.version)} is ready.</span>${pill('Restart to update', 'installupdate', 'p')}`;
    else if (u.state === 'downloading') html = `<span>Downloading version ${esc(u.version)}...</span>`;
    else if (u.state === 'installing') html = `<span>Installing version ${esc(u.version)}. ONN Companion will reopen by itself.</span>`;
    else if (u.state === 'available') html = `<span>Version ${esc(u.version)} is out.</span>${pill('Download', 'open:/companion', 'p')}`;
    bar.innerHTML = html; bar.hidden = !html;
    btn.disabled = u.state === 'checking' || u.state === 'downloading' || u.state === 'installing';
    btn.textContent = u.state === 'checking' ? 'Checking...' : u.state === 'ready' ? 'Restart to update' : 'Check for updates';
    btn.dataset.do = u.state === 'ready' ? 'installupdate' : 'checkupdate';
  }
  async function refresh() {
    await refreshInfo();
    if (!info.signedIn) { feed = null; render(); return; }
    try { feed = await invoke('feed'); } catch (e) { if (String(e).includes('signed out')) { await refreshInfo(); feed = null; } }
    render(true);
  }
  async function act(cmd, args, ok) {
    try { await invoke(cmd, args); say(ok); } catch (e) { say("Couldn't do that: " + e, true); }
    refresh();
  }
  const open = (path) => invoke('open_page', { path }).catch((e) => say(String(e), true));

  // ---------- pieces ----------
  const pill = (label, action, cls = '') => `<button class="pill ${cls}" data-do="${esc(action)}">${esc(label)}</button>`;
  function card({ key, src, time, title, detail, more, acts = '', url, openLabel = 'Open', hot, titleCls = '' }) {
    const isOpen = expanded === key;
    return `<div class="card${hot ? ' hot' : ''}${isOpen ? ' open' : ''}" data-card="${esc(key)}" role="button" tabindex="0">
      <div class="ch"><span>${esc(src)}</span><span>${esc(time || '')}</span></div>
      <div class="ct ${titleCls}">${esc(title)}</div>
      ${detail ? `<div class="cd">${esc(detail)}</div>` : ''}
      ${isOpen && more ? `<div class="more">${esc(more)}</div>` : ''}
      ${acts || (isOpen && url) ? `<div class="acts">${acts}${isOpen && url ? pill(openLabel + ' ›', 'open:' + url) : ''}</div>` : ''}
    </div>`;
  }
  function eventCard(e) {
    const hot = (Date.parse(e.startsAt) - Date.now()) < 3 * 3600e3;
    const acts = [['yes', 'Going'], ['maybe', 'Maybe'], ['no', "Can't"]].map(([k, l]) => pill(l, `rsvp:${e.id}:${k}:${e.title}`, e.myRsvp === k ? 'on' : '')).join('');
    return card({ key: 'ev:' + e.id, src: e.guild, time: soon(e.startsAt), title: e.title, detail: `${when(e.startsAt)} - ${e.going} going${e.maybe ? `, ${e.maybe} maybe` : ''}`,
      more: [e.body, e.author ? `Posted by ${e.author}` : ''].filter(Boolean).join('\n\n'), acts, url: e.url, openLabel: 'See who is going', hot });
  }
  function supplyCard(x, mode) {
    const title = `${n(x.qty)}x ${x.item}`;
    let detail, acts;
    if (mode === 'open') {
      detail = `for ${x.requester} - mail to ${x.deliverTo}${x.offer ? ` - offering ${x.offer}` : ''}${x.guild ? ` - ${x.guild} only` : ''}`;
      acts = pill("I'll supply this", `supply:${x.id}:claim:You're supplying ${x.item}. Mail it to ${x.deliverTo}.`, 'p');
    } else if (mode === 'supplying') {
      detail = `Mail it in game to ${x.deliverTo} for ${x.requester}`;
      acts = pill("Can't do it after all", `supply:${x.id}:unclaim:Back on the board for someone else.`);
    } else {
      detail = x.status === 'claimed' ? `${x.claimer} is supplying this - mail to ${x.deliverTo}` : `Waiting for a supplier - mail to ${x.deliverTo}`;
      acts = pill('Got it', `supply:${x.id}:delivered:Marked ${x.item} as received.`, 'p') + pill('Cancel', `supply:${x.id}:cancel:Request cancelled.`);
    }
    const more = [x.purpose && 'For: ' + x.purpose, x.needBy && 'Needed by ' + new Date(x.needBy + 'T12:00:00').toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }), x.category && 'Category: ' + x.category].filter(Boolean).join('\n');
    return card({ key: `sup:${x.id}:${mode}`, src: mode === 'open' ? 'Supply request' : mode === 'supplying' ? "You're supplying" : 'Your request', time: ago(x.created), title, detail, more, acts, url: '/supply', openLabel: 'Full supply board', titleCls: x.itemQ != null ? 'q' + x.itemQ : '' });
  }
  const postCard = (p) => card({ key: 'post:' + p.id, src: p.guild, time: ago(p.activity), title: (p.pinned ? 'Pinned: ' : '') + p.title, detail: `${p.author} - ${p.replies} replies`, more: p.body, url: p.url, openLabel: 'Open and reply' });
  const newsCard = (x) => card({ key: 'news:' + x.url, src: x.source, time: ago(x.published), title: x.title, more: x.summary, url: x.url.replace('https://olympusnewsnetwork.com', ''), openLabel: 'Read the story' });
  const section = (h, body) => `<div class="sh">${esc(h)}</div>${body}`;
  const empty = (t) => `<p class="empty">${esc(t)}</p>`;
  const tile = (icon, value, label, on, go) => `<button class="tile${on ? ' on' : ''}" data-do="${esc(go)}">${svg(icon)}<span class="tv">${value}</span><span class="tl">${esc(label)}</span></button>`;

  // ---------- screens ----------
  function render(keep) {
    const c = $('content');
    const top = keep ? c.scrollTop : 0;
    document.querySelectorAll('#tabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === tab));
    let html = '';
    if (flash && Date.now() < flash.until) html += `<div class="flash${flash.bad ? ' bad' : ''}">${esc(flash.text)}</div>`; else flash = null;
    const hk = info.hotkey || 'the tray icon';
    document.body.classList.toggle('left', info.side === 'left');
    $('gear').setAttribute('aria-pressed', tab === 'settings');
    if (tab === 'settings') {
      const cur = scr || { screens: [], monitor: '', side: 'right' };
      const scrOpts = [pill('Where my mouse is', 'place:mon:', cur.monitor ? '' : 'on')]
        .concat(cur.screens.map((m) => pill(m.label + (m.primary ? ', main' : ''), 'place:mon:' + m.key, cur.monitor === m.key ? 'on' : ''))).join('');
      const sideOpts = pill('Left', 'place:side:left', cur.side === 'left' ? 'on' : '') + pill('Right', 'place:side:right', cur.side !== 'left' ? 'on' : '');
      html += `<div class="set"><h2>Settings</h2>
        <div class="sh">Screen</div><p class="muted">Which screen the panel opens on.</p><div class="opts">${scrOpts}</div>
        <div class="sh">Side</div><p class="muted">Which edge of the screen it sits on.</p><div class="opts">${sideOpts}</div>
        <div class="sh">Updates</div><p class="muted">ONN Companion ${esc(info.version || '')}. New versions install by themselves after you close the panel.</p><div class="opts">${pill('Check for updates', 'checkupdate')}</div>
        <div class="acts" style="margin-top:16px">${pill('Done', 'tab:' + lastTab, 'p')}</div></div>`;
      c.innerHTML = html;
      if (keep) c.scrollTop = top;
      $('foot').textContent = `ONN Companion ${info.version || ''} - ${hk} to open or close`;
      return;
    }
    if (!info.signedIn) {
      $('hTitle').textContent = 'ONN'; $('hSub').textContent = 'Not signed in';
      html += info.linking
        ? `<div class="signin"><h2>Finish in your browser</h2><p>A sign-in page opened in your web browser. Sign in with your Archway ID there and click <b>Approve</b>. Check the code matches:</p>
            <p class="code">${esc(info.linking)}</p><p>This panel signs in by itself a few seconds after you approve.</p>
            <div class="acts">${pill('Open the sign-in page again', 'signin', 'p')}${pill('Cancel', 'cancelsignin')}</div></div>`
        : `<div class="signin"><h2>Sign in to ONN Companion</h2><p>See your guild events, supply runs and guild boards right over the game. Sign in once with your Archway ID; the sign-in page opens in your web browser.</p>
            ${pill('Sign in with Archway ID', 'signin', 'p')}</div>`;
      c.innerHTML = html;
      $('foot').textContent = `ONN Companion ${info.version || ''} - ${hk} to open or close`;
      return;
    }
    const f = feed;
    if (!f) { c.innerHTML = html + empty('Loading...'); return; }
    $('hTitle').textContent = f.me.display ? 'Hi, ' + f.me.display : 'ONN';
    const m = f.me.main;
    $('hSub').textContent = m ? [m.name, m.level, m.guild].filter(Boolean).join(' - ') : 'Olympus News Network';
    const t = f.tiles;
    if (tab === 'home') {
      html += `<div class="tiles">${tile('box', n(t.open), 'Open requests', t.open > 0, 'tab:supply')}${tile('mail', n(t.supplying), "You're supplying", t.supplying > 0, 'tab:supply')}${tile('cal', n(t.events), 'Events this week', t.events > 0, 'tab:events')}${tile('dot', t.online == null ? '-' : n(t.online), 'Olympus online', false, 'open:/army')}${tile('users', '&nbsp;', 'Find players', false, 'open:/players')}${tile('plus', '&nbsp;', 'Request supplies', false, 'open:/supply?new=1#new-request')}</div>`;
      if (f.events.length) html += section('Coming up', f.events.slice(0, 3).map(eventCard).join(''));
      if (f.supplying.length) html += section("You're supplying", f.supplying.slice(0, 3).map((x) => supplyCard(x, 'supplying')).join(''));
      if (f.supplyOpen.length) html += section('Supply runs you could fill', f.supplyOpen.slice(0, 3).map((x) => supplyCard(x, 'open')).join(''));
      if (f.posts.length) html += section('Guild boards', f.posts.slice(0, 3).map(postCard).join(''));
      if (f.news.length) html += section('News', f.news.slice(0, 3).map(newsCard).join(''));
    } else if (tab === 'supply') {
      html += `<div class="acts">${pill('Request supplies', 'open:/supply?new=1#new-request', 'p')}${pill('Full supply board', 'open:/supply')}</div>`;
      html += section("You're supplying", f.supplying.length ? f.supplying.map((x) => supplyCard(x, 'supplying')).join('') : empty('Nothing right now. Pick one up below.'));
      html += section('Open requests', f.supplyOpen.length ? f.supplyOpen.map((x) => supplyCard(x, 'open')).join('') : empty('No open requests.'));
      html += section('Your requests', f.myRequests.length ? f.myRequests.map((x) => supplyCard(x, 'mine')).join('') : empty("You haven't asked for anything."));
    } else if (tab === 'events') {
      html += section('Next 7 days', f.events.length ? f.events.map(eventCard).join('') : empty('No events coming up in your guild spaces.'));
    } else if (tab === 'guilds') {
      html += section('Your guild spaces', f.guilds.length ? f.guilds.map((g) => card({ key: 'g:' + g.id, src: g.role === 'leader' ? 'Guild master' : g.role === 'pending' ? 'Waiting to be let in' : g.role, title: g.name, url: g.url, openLabel: 'Open guild space' })).join('') : empty("You're not in a guild space yet.") + `<div class="acts">${pill('Find your guild', 'open:/guilds', 'p')}</div>`);
      if (f.joinRequests > 0) html += card({ key: 'joins', src: 'Join requests', title: `${f.joinRequests} waiting to join`, detail: 'Check them in game, then let them in.', url: '/guilds', hot: true });
      html += section('Latest on the boards', f.posts.length ? f.posts.map(postCard).join('') : empty('Nothing posted yet.'));
    } else if (tab === 'news') {
      html += section('Latest news', f.news.map(newsCard).join(''));
    }
    c.innerHTML = html;
    if (keep) c.scrollTop = top;
    const u = f.uploader;
    const up = !u ? 'census uploads start when WoW saves' : u.status === 'pending' ? 'census uploads waiting for approval' : info.lastUpload ? 'census sent ' + ago(info.lastUpload) : 'census uploads on';
    $('foot').textContent = `${up} - ${hk} to open or close - v${info.version}`;
  }

  // ---------- clicks ----------
  document.addEventListener('click', (e) => {
    const d = e.target.closest('[data-do]');
    if (d) {
      e.stopPropagation();
      const a = d.dataset.do;
      if (a === 'signin') return invoke('start_signin').then(refresh).catch((err) => say(String(err), true));
      if (a === 'cancelsignin') return invoke('cancel_signin').then(refresh);
      if (a === 'checkupdate') { info.update = { ...(info.update || {}), state: 'checking' }; drawUpdate(); return invoke('check_for_updates').then(refreshInfo); }
      if (a === 'installupdate') return invoke('install_update').catch((err) => say(String(err), true));
      if (a.startsWith('tab:')) { tab = a.slice(4); expanded = ''; return render(); }
      if (a.startsWith('place:')) {
        const [, kind, ...rest] = a.split(':'); const val = rest.join(':');
        const cur = scr || { monitor: '', side: 'right' };
        const monitor = kind === 'mon' ? val : cur.monitor, side = kind === 'side' ? val : cur.side;
        return invoke('set_placement', { monitor, side }).then(loadScreens).then(refreshInfo).then(() => render(true));
      }
      if (a.startsWith('open:')) return open(a.slice(5));
      if (a.startsWith('rsvp:')) { const [, id, status, ...rest] = a.split(':'); return act('rsvp', { id, status }, 'Answered ' + rest.join(':')); }
      if (a.startsWith('supply:')) { const [, id, action, ...rest] = a.split(':'); return act('supply_action', { id, action }, rest.join(':')); }
      return;
    }
    const c = e.target.closest('[data-card]');
    if (c) { expanded = expanded === c.dataset.card ? '' : c.dataset.card; render(true); }
  });
  $('tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (b) { tab = b.dataset.tab; expanded = ''; render(); } });
  $('close').addEventListener('click', () => invoke('hide_overlay'));
  async function loadScreens() { try { scr = await invoke('screens'); } catch { scr = null; } }
  $('gear').addEventListener('click', async () => {
    if (tab === 'settings') { tab = lastTab; return render(); }
    lastTab = tab; tab = 'settings'; await loadScreens(); render();
  });
  $('pin').addEventListener('click', () => { pinned = !pinned; $('pin').setAttribute('aria-pressed', pinned); invoke('set_pinned', { on: pinned }); });
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') invoke('hide_overlay'); });

  listen('overlay-shown', () => refresh());
  listen('state-changed', () => refresh());
  listen('say', (e) => say(e.payload.text));
  setInterval(() => { if (!document.hidden) refresh(); }, 60000);
  setInterval(() => { if (info.linking) refreshInfo().then(() => { if (!info.linking) refresh(); else render(true); }); }, 3000);
  refresh();
})();
src-tauri/src/lib.rs943 lines
// ONN Companion 3 (cross-platform): the Olympus overlay for WoW: Forever.
// Copyright (c) 2026 Archway Point. All rights reserved.
//
// - A see-through panel that slides in over the game from the right edge (hotkey or tray icon)
// - Signs in with Archway ID through a device link (no keys), token kept in the system's secure store
// - Sends the Olympus Guild addon's census whenever WoW saves it
// - Shows ONN pages inside the overlay; links to other sites open in your browser
mod paths;
mod store;

use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt;

pub const SITE: &str = "https://olympusnewsnetwork.com";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const PANEL_W: f64 = 420.0;

#[derive(Default)]
struct Inner {
    visible: bool,
    pinned: bool,
    hidden_at: Option<Instant>,
    linking: Option<(String, String, Instant)>, // code, secret, expires
    uploader_status: String,
    last_error: String,
    files: Vec<PathBuf>,
    last_scan: Option<Instant>,
    hold_until: Option<Instant>,
    notified: HashSet<String>,
    joins_seen: i64,
    hotkey: String,
    web_signed: bool,
    home: Option<tauri::Url>,
    grace_until: Option<Instant>, // ignore focus loss briefly (page loads, opening links)
    shown_seq: u64, // bumps every time the panel opens
    ack_seq: u64,   // last open the page confirmed it actually drew
    nav_seq: u64,   // bumps on every page change inside the panel
    loaded_nav: u64, // last page change that finished loading
    // Updates: "" (never checked), checking, none, available (can't self-install), downloading,
    // ready, installing, error.
    upd_state: String,
    upd_version: String,
    upd_error: String,
    upd_checked: Option<Instant>,
}
struct AppState(Mutex<Inner>);

fn lk(app: &AppHandle) -> std::sync::MutexGuard<'_, Inner> {
    app.state::<AppState>().inner().0.lock().unwrap_or_else(|e| e.into_inner())
}

/// Keeps the panel open through focus hiccups we cause ourselves (loading a page, opening a link).
fn keep_open(app: &AppHandle, ms: u64) {
    lk(app).grace_until = Some(Instant::now() + Duration::from_millis(ms));
}

fn client() -> &'static reqwest::Client {
    static C: OnceLock<reqwest::Client> = OnceLock::new();
    C.get_or_init(|| {
        reqwest::Client::builder()
            .user_agent(format!("ONN-Companion/{VERSION} ({})", std::env::consts::OS))
            .timeout(Duration::from_secs(60))
            .build()
            .expect("http client")
    })
}

fn device_name() -> String {
    std::env::var("COMPUTERNAME")
        .or_else(|_| std::env::var("HOSTNAME"))
        .ok()
        .or_else(|| std::fs::read_to_string("/etc/hostname").ok().map(|s| s.trim().to_string()))
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| format!("{} PC", std::env::consts::OS))
}

/// Calls the ONN API. Returns (HTTP status, JSON body).
async fn api(method: &str, path: &str, body: Option<Value>, auth: bool) -> Result<(u16, Value), String> {
    let url = format!("{SITE}{path}");
    let mut rb = match method {
        "POST" => client().post(&url),
        _ => client().get(&url),
    };
    if auth {
        match store::get_token() {
            Some(t) => rb = rb.bearer_auth(t),
            None => return Err("not signed in".into()),
        }
    }
    if let Some(b) = body {
        rb = rb.json(&b);
    }
    let r = rb.send().await.map_err(|e| e.to_string())?;
    let status = r.status().as_u16();
    let v = r.json::<Value>().await.unwrap_or(Value::Null);
    Ok((status, v))
}

fn say(app: &AppHandle, title: &str, text: &str) {
    let visible = { let s = lk(&app); s.visible };
    if visible {
        let _ = app.emit("say", json!({ "text": format!("{title}: {text}") }));
    } else {
        let _ = app.notification().builder().title(title).body(text).show();
    }
}

// ---------- the panel ----------
fn overlay(app: &AppHandle) -> Option<WebviewWindow> { app.get_webview_window("overlay") }

fn place(w: &WebviewWindow) {
    // Use the screen your mouse is on (where you're playing), in real pixels so mixed-DPI
    // setups can't push the panel off the edge of the screen.
    let st = store::load_settings();
    // A screen you picked in Settings, if it's still connected; otherwise the one your mouse is on.
    let chosen = if st.monitor.is_empty() { None } else {
        w.available_monitors().ok().and_then(|ms| ms.into_iter().find(|m| m.name().map(|n| n == &st.monitor).unwrap_or(false)))
    };
    let mon = chosen
        .or_else(|| w.cursor_position().ok().and_then(|c| w.monitor_from_point(c.x, c.y).ok().flatten()))
        .or_else(|| w.current_monitor().ok().flatten())
        .or_else(|| w.primary_monitor().ok().flatten());
    if let Some(m) = mon {
        let scale = m.scale_factor();
        let wa = m.work_area();
        let pw = (PANEL_W * scale).round() as i32;
        let ph = ((wa.size.height as f64) - 8.0 * scale).max(520.0 * scale).min(wa.size.height as f64).round() as i32;
        let gap = (2.0 * scale).round() as i32;
        let x = if st.side == "left" { wa.position.x + gap } else { wa.position.x + wa.size.width as i32 - pw - gap };
        let y = wa.position.y + (4.0 * scale).round() as i32;
        let _ = w.set_size(tauri::PhysicalSize::new(pw.max(1) as u32, ph.max(1) as u32));
        let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
    }
}
fn show(app: &AppHandle) {
    if let Some(w) = overlay(app) {
        place(&w);
        let _ = w.unminimize();
        let _ = w.show();
        // Re-assert "on top": Windows sometimes drops it after the panel is hidden.
        let _ = w.set_always_on_top(false);
        let _ = w.set_always_on_top(true);
        let _ = w.set_focus();
        let seq = { let mut s = lk(app); s.visible = true; s.shown_seq += 1; s.shown_seq };
        store::log(&format!("show #{seq}: pos {:?} size {:?} visible {:?} min {:?} scale {:?} url {:?}",
            w.outer_position().ok().map(|p| (p.x, p.y)), w.outer_size().ok().map(|s| (s.width, s.height)),
            w.is_visible().ok(), w.is_minimized().ok(), w.scale_factor().ok(), w.url().ok().map(|u| u.to_string())));
        let _ = app.emit("overlay-shown", ());
        watch_draw(app.clone(), seq);
    }
}

/// The panel is see-through, so if its web view stops drawing (renderer crashed, stuck page load,
/// GPU reset after sleep) you'd get an invisible window. After each open, ask the page to confirm
/// it drew a frame; if it doesn't, reload the panel, and if that fails too, rebuild the window.
fn watch_draw(app: AppHandle, seq: u64) {
    tauri::async_runtime::spawn(async move {
        for attempt in 0..3u8 {
            if let Some(w) = overlay(&app) {
                let _ = w.eval(&format!("window.__onnAck && window.__onnAck({seq})"));
            }
            // Give a page that's still loading time to finish before deciding it's stuck.
            tokio::time::sleep(Duration::from_millis(if attempt == 0 { 3000 } else { 2000 })).await;
            let (ok, still) = { let s = lk(&app); (s.ack_seq >= seq, s.visible && s.shown_seq == seq) };
            if ok || !still { return; }
            store::log(&format!("overlay did not draw (try {}), recovering", attempt + 1));
            match attempt {
                0 => reset_panel(&app),
                1 => { if let Some(w) = overlay(&app) { let _ = w.hide(); place(&w); let _ = w.show(); let _ = w.set_focus(); } }
                _ => {}
            }
        }
    });
}
fn hide(app: &AppHandle) {
    if let Some(w) = overlay(app) {
        let _ = w.hide();
    }
    { let mut s = lk(app);
        s.visible = false;
        s.hidden_at = Some(Instant::now());
    }
}
/// Clicking the tray icon while the panel is open first takes focus away (which hides it), so a
/// click right after that hide means "close", not "open again".
fn toggle(app: &AppHandle) {
    let (visible, recent) = {
        let s = lk(&app);
        (s.visible, s.hidden_at.map(|t| t.elapsed() < Duration::from_millis(400)).unwrap_or(false))
    };
    if visible { hide(app) } else if !recent { show(app) }
}

fn local_home() -> Option<tauri::Url> {
    // Where Tauri serves the bundled panel page on each platform.
    let base = if cfg!(windows) || cfg!(target_os = "android") { "http://tauri.localhost/" } else { "tauri://localhost/" };
    base.parse().ok()
}

fn is_ours(u: &tauri::Url) -> bool { matches!(u.host_str(), Some("olympusnewsnetwork.com") | Some("www.olympusnewsnetwork.com")) }
fn is_signin(u: &tauri::Url) -> bool { u.host_str().map(|h| h == "archwaypoint.com" || h.ends_with(".archwaypoint.com")).unwrap_or(false) }
fn is_local(u: &tauri::Url) -> bool { matches!(u.scheme(), "tauri" | "asset") || matches!(u.host_str(), Some("tauri.localhost") | Some("localhost")) }

// A slim bar on ONN pages shown inside the overlay: back, home and close.
const PAGE_BAR: &str = r#"
(function () {
  // Lets the app check that the panel really drew after opening (see watch_draw).
  window.__onnAck = function (n) { requestAnimationFrame(function () { requestAnimationFrame(function () { try { window.__TAURI__.core.invoke('overlay_ack', { n: n }); } catch (e) {} }); }); };
  window.addEventListener('pageshow', function () { window.__onnAck(0); });
  var h = location.hostname;
  var ours = h === 'olympusnewsnetwork.com' || h === 'www.olympusnewsnetwork.com';
  var signin = h === 'archwaypoint.com' || /\.archwaypoint\.com$/.test(h);
  if (!ours && !signin) return;
  // Paint the panel's backdrop from the very first moment, so a redirect or a page that's still
  // loading shows a dark panel instead of nothing at all on the see-through window.
  try {
    var bd = document.createElement('style');
    bd.textContent = 'html{background:transparent!important}html::before{content:"";position:fixed;top:6px;right:6px;bottom:6px;left:10px;background:#0c1322;border:1px solid #2a3a5c;border-radius:14px;z-index:-2147483647;pointer-events:none}';
    (document.head || document.documentElement).appendChild(bd);
  } catch (e) {}
  function add() {
    if (document.getElementById('onn-overlay-bar')) return;
    // Keep the overlay's rounded, see-through panel shape around web pages.
    var css = document.createElement('style');
    css.textContent = 'html{background:transparent!important}body{margin:6px 6px 6px 10px!important;border:1px solid #2a3a5c;border-radius:14px;overflow-x:hidden;min-height:calc(100vh - 14px);box-shadow:0 6px 26px rgba(0,0,0,.55)}body::-webkit-scrollbar{display:none}'
      + '#onn-overlay-bar{position:sticky;top:0;z-index:2147483647;display:flex;gap:6px;align-items:center;padding:8px 10px;background:rgba(12,19,34,.97);border-bottom:1px solid #2a3a5c;border-radius:14px 14px 0 0;font:600 12.5px "Segoe UI",system-ui,sans-serif}#onn-overlay-bar span{flex:1}#onn-overlay-bar button{background:#1a2540;color:#ece8dc;border:0;border-radius:8px;padding:5px 11px;font:inherit;cursor:pointer}#onn-overlay-bar button:hover{background:#22304f}';
    document.head.appendChild(css);
    var bar = document.createElement('div');
    bar.id = 'onn-overlay-bar';
    bar.innerHTML = '<button data-a="back">\u2039 Back</button><span></span><button data-a="home" title="Overlay home">Home</button><button data-a="close" title="Close (Esc)">\u2715</button>';
    document.body.insertBefore(bar, document.body.firstChild);
    var inv = function (c) { try { window.__TAURI__.core.invoke(c); } catch (e) {} };
    bar.addEventListener('click', function (e) {
      var a = e.target.closest('button'); if (!a) return;
      if (a.dataset.a === 'back') { if (ours && history.length > 1 && document.referrer.indexOf('olympusnewsnetwork.com') > -1) history.back(); else inv('go_home'); }
      if (a.dataset.a === 'home') inv('go_home');
      if (a.dataset.a === 'close') inv('hide_overlay');
    });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') inv('go_home'); });
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add();
})();
"#;

// ---------- updates ----------
// The app checks olympusnewsnetwork.com for a newer signed build a minute after it starts and then
// every 4 hours. Windows and AppImage copies download it in the background and install it the
// next time the panel has been closed for a minute (or straight away from "Restart to update").
// A .deb install can't replace itself, so it only says a new version is out.
static PENDING: OnceLock<Mutex<Option<(tauri_plugin_updater::Update, Vec<u8>)>>> = OnceLock::new();
fn pending() -> std::sync::MutexGuard<'static, Option<(tauri_plugin_updater::Update, Vec<u8>)>> {
    PENDING.get_or_init(|| Mutex::new(None)).lock().unwrap_or_else(|e| e.into_inner())
}
static CHECKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

fn can_self_update() -> bool {
    if cfg!(windows) || cfg!(target_os = "macos") { return true; }
    std::env::var_os("APPIMAGE").is_some()
}
fn install_kind() -> &'static str {
    if cfg!(windows) { "nsis" } else if cfg!(target_os = "macos") { "app" } else if std::env::var_os("APPIMAGE").is_some() { "appimage" } else { "deb" }
}
fn set_upd(app: &AppHandle, state: &str, version: Option<&str>, err: &str) {
    { let mut s = lk(app);
        s.upd_state = state.to_string();
        if let Some(v) = version { s.upd_version = v.to_string(); }
        s.upd_error = err.to_string();
    }
    let _ = app.emit("state-changed", ());
}

/// Look for an update; download it when this copy can install it. `manual` = the person asked.
async fn check_updates(app: &AppHandle, manual: bool) {
    use tauri_plugin_updater::UpdaterExt;
    if CHECKING.swap(true, std::sync::atomic::Ordering::SeqCst) { return; }
    struct Done; impl Drop for Done { fn drop(&mut self) { CHECKING.store(false, std::sync::atomic::Ordering::SeqCst); } }
    let _done = Done;
    lk(app).upd_checked = Some(Instant::now());
    // Already downloaded and waiting to install.
    if pending().is_some() {
        if manual { let v = lk(app).upd_version.clone(); say(app, "ONN Companion", &format!("Version {v} is ready. Click Restart to update.")); }
        return;
    }
    set_upd(app, "checking", None, "");
    let mut ub = app.updater_builder();
    // For testing against another server. Builds are still checked against ONN's signing key.
    if let Some(ep) = std::env::var("ONN_UPDATE_ENDPOINT").ok().and_then(|e| e.parse().ok()) {
        ub = match ub.endpoints(vec![ep]) { Ok(b) => b, Err(e) => { store::log(&format!("bad ONN_UPDATE_ENDPOINT: {e}")); return; } };
    }
    // A test channel (ONN_UPDATE_CHANNEL=test) gets builds that aren't out for everyone yet.
    if let Ok(ch) = std::env::var("ONN_UPDATE_CHANNEL") {
        if !ch.is_empty() && ch.len() <= 20 && ch.chars().all(|c| c.is_ascii_alphanumeric()) {
            ub = match ub.header("x-onn-channel", ch) { Ok(b) => b, Err(_) => return };
        }
    }
    let updater = match ub.header("x-onn-install", install_kind()).and_then(|b| b.build()) {
        Ok(u) => u,
        Err(e) => { store::log(&format!("update check setup failed: {e}")); set_upd(app, "error", None, "couldn't check for updates"); return; }
    };
    let found = match updater.check().await {
        Ok(f) => f,
        Err(e) => {
            store::log(&format!("update check failed: {e}"));
            set_upd(app, "error", None, "couldn't reach olympusnewsnetwork.com");
            if manual { say(app, "ONN Companion", "Couldn't check for updates right now. Try again in a bit."); }
            return;
        }
    };
    let Some(update) = found else {
        set_upd(app, "none", None, "");
        if manual { say(app, "ONN Companion", &format!("You're on the latest version ({VERSION}).")); }
        return;
    };
    let ver = update.version.clone();
    if !can_self_update() {
        let first = lk(app).upd_version != ver;
        set_upd(app, "available", Some(&ver), "");
        if first || manual { say(app, "ONN Companion", &format!("Version {ver} is out. Download it from olympusnewsnetwork.com/companion.")); }
        return;
    }
    store::log(&format!("downloading update {ver}"));
    set_upd(app, "downloading", Some(&ver), "");
    match update.download(|_, _| {}, || {}).await {
        Ok(bytes) => {
            *pending() = Some((update, bytes));
            store::log(&format!("update {ver} downloaded"));
            set_upd(app, "ready", Some(&ver), "");
            let visible = lk(app).visible;
            if manual || visible { say(app, "ONN Companion", &format!("Version {ver} is ready. Click Restart to update, or it installs by itself after you close the panel.")); }
        }
        Err(e) => {
            store::log(&format!("update download failed: {e}"));
            set_upd(app, "error", Some(&ver), "the update didn't download");
            if manual { say(app, "ONN Companion", "The update didn't download. Try again in a bit."); }
        }
    }
}

/// Install a downloaded update now. The app closes and the new version starts by itself.
fn install_pending(app: &AppHandle) -> Result<(), String> {
    let Some((update, bytes)) = pending().take() else { return Err("No update is downloaded yet.".into()) };
    let ver = update.version.clone();
    store::log(&format!("installing update {ver}"));
    set_upd(app, "installing", Some(&ver), "");
    if let Some(w) = overlay(app) { let _ = w.hide(); }
    match update.install(bytes) {
        Ok(()) => { app.restart(); } // Windows exits inside install(); Linux/macOS restart here
        Err(e) => {
            store::log(&format!("update install failed: {e}"));
            set_upd(app, "error", Some(&ver), "the update didn't install");
            Err(format!("The update didn't install: {e}"))
        }
    }
}

/// Called every 20 seconds: checks on schedule, and installs a ready update while you're not using the panel.
async fn update_tick(app: &AppHandle) {
    let (due, idle) = {
        let s = lk(app);
        let due = match s.upd_checked { None => false, Some(t) => t.elapsed() > Duration::from_secs(4 * 3600) };
        let idle = !s.visible && !s.pinned && s.linking.is_none() && s.hidden_at.map(|t| t.elapsed() > Duration::from_secs(60)).unwrap_or(true);
        (due, idle)
    };
    if due { check_updates(app, false).await; }
    let ready = lk(app).upd_state == "ready";
    if ready && idle {
        let _ = install_pending(app);
    }
}

#[tauri::command]
async fn check_for_updates(app: AppHandle) {
    check_updates(&app, true).await;
}

#[tauri::command]
fn install_update(app: AppHandle) -> Result<(), String> {
    install_pending(&app)
}

// ---------- commands the overlay calls ----------
#[tauri::command]
fn app_info(state: tauri::State<AppState>) -> Value {
    let s = state.0.lock().unwrap();
    let st = store::load_settings();
    json!({
        "version": VERSION, "os": std::env::consts::OS,
        "signedIn": store::get_token().is_some(), "display": st.display,
        "hotkey": s.hotkey, "uploader": s.uploader_status, "lastError": s.last_error,
        "lastUpload": store::load_uploads().last_upload, "files": s.files.len(),
        "linking": s.linking.as_ref().map(|l| l.0.clone()), "pinned": s.pinned,
        "side": if st.side == "left" { "left" } else { "right" },
        "update": { "state": s.upd_state, "version": s.upd_version, "error": s.upd_error, "selfInstall": can_self_update() },
    })
}

#[tauri::command]
async fn feed() -> Result<Value, String> {
    let (st, v) = api("GET", "/api/app/feed", None, true).await?;
    if st == 401 { store::set_token(None); return Err("signed out".into()); }
    if st != 200 { return Err(v.get("error").and_then(|e| e.as_str()).unwrap_or("couldn't load").to_string()); }
    Ok(v)
}

#[tauri::command]
async fn supply_action(id: String, action: String) -> Result<Value, String> {
    let (st, v) = api("POST", "/api/app/supply", Some(json!({ "id": id, "action": action })), true).await?;
    if st == 200 { Ok(v) } else { Err(v.get("error").and_then(|e| e.as_str()).unwrap_or("that didn't work").to_string()) }
}

#[tauri::command]
async fn rsvp(id: String, status: String) -> Result<Value, String> {
    let (st, v) = api("POST", "/api/app/rsvp", Some(json!({ "id": id, "status": status })), true).await?;
    if st == 200 { Ok(v) } else { Err(v.get("error").and_then(|e| e.as_str()).unwrap_or("that didn't work").to_string()) }
}

fn safe_path(p: &str) -> String {
    let ok = p.starts_with('/') && !p.starts_with("//") && p.len() < 300 && p.chars().all(|c| c.is_ascii_alphanumeric() || "-_/?=&#.%".contains(c));
    if ok { p.to_string() } else { "/app".to_string() }
}

/// Shows an ONN page inside the overlay. The first page each run signs the page in with the app's own sign-in.
#[tauri::command]
async fn open_page(app: AppHandle, path: String) -> Result<(), String> {
    let path = safe_path(&path);
    let with = format!("{path}{}overlay=1", if path.contains('?') { "&" } else { "?" });
    let signed = lk(&app).web_signed;
    let mut target = format!("{SITE}{with}");
    if !signed && store::get_token().is_some() {
        if let Ok((200, v)) = api("POST", "/api/app/websession", Some(json!({})), true).await {
            if let Some(u) = v.get("url").and_then(|u| u.as_str()) {
                target = format!("{SITE}{u}?next={}", urlencoding::encode(&with));
                lk(&app).web_signed = true;
            }
        }
    }
    keep_open(&app, 2000);
    let w = overlay(&app).ok_or("no window")?;
    if !lk(&app).visible { show(&app); }
    w.navigate(target.parse().map_err(|_| "bad url")?).map_err(|e| e.to_string())
}

#[tauri::command]
fn go_home(app: AppHandle) {
    keep_open(&app, 1500);
    let home = lk(&app).home.clone();
    if let (Some(w), Some(h)) = (overlay(&app), home) {
        let _ = w.navigate(h);
    }
}

#[tauri::command]
fn hide_overlay(app: AppHandle) { hide(&app) }

#[tauri::command]
fn overlay_ack(app: AppHandle, n: u64) {
    // n = 0: a page just finished loading and drew, which counts for the current open.
    let mut s = lk(&app);
    if n == 0 { s.loaded_nav = s.nav_seq; }
    let n = if n == 0 { s.shown_seq } else { n };
    if n > s.ack_seq { s.ack_seq = n; }
    drop(s);
    // A page just loaded: nudge the window so Windows redraws the see-through panel. Switching
    // between sites (ONN, Archway ID sign-in, the panel's own home) can otherwise leave it blank.
    repaint(&app);
}

fn repaint(app: &AppHandle) {
    #[cfg(windows)]
    {
        let a = app.clone();
        tauri::async_runtime::spawn(async move {
            tokio::time::sleep(Duration::from_millis(60)).await;
            if let Some(w) = overlay(&a) {
                if let Ok(sz) = w.inner_size() {
                    let _ = w.set_size(tauri::PhysicalSize::new(sz.width, sz.height.saturating_sub(1).max(1)));
                    tokio::time::sleep(Duration::from_millis(30)).await;
                    let _ = w.set_size(sz);
                }
            }
        });
    }
    #[cfg(not(windows))]
    let _ = app;
}

/// Sends the panel back to its home screen (tray menu "Reset overlay", and stuck pages).
fn reset_panel(app: &AppHandle) {
    let home = lk(app).home.clone();
    if let (Some(w), Some(h)) = (overlay(app), home) {
        let _ = w.navigate(h);
        place(&w);
    }
    repaint(app);
}

/// If a page inside the panel never finishes loading (a redirect that went nowhere), go home.
fn watch_nav(app: AppHandle, seq: u64) {
    tauri::async_runtime::spawn(async move {
        tokio::time::sleep(Duration::from_secs(12)).await;
        let stuck = { let s = lk(&app); s.visible && s.nav_seq == seq && s.loaded_nav < seq };
        if stuck {
            store::log("page in the overlay never loaded, going home");
            reset_panel(&app);
        }
    });
}

/// Screens you can pin the panel to (Settings in the panel).
#[tauri::command]
fn screens(app: AppHandle) -> Value {
    let st = store::load_settings();
    let Some(w) = overlay(&app) else { return json!({ "screens": [], "monitor": st.monitor, "side": st.side }) };
    let primary = w.primary_monitor().ok().flatten().and_then(|m| m.name().cloned());
    let list: Vec<Value> = w.available_monitors().unwrap_or_default().iter().enumerate().map(|(i, m)| {
        let name = m.name().cloned().unwrap_or_else(|| format!("screen-{i}"));
        let sz = m.size();
        json!({ "key": name, "label": format!("Screen {} ({}x{})", i + 1, sz.width, sz.height), "primary": primary.as_deref() == Some(name.as_str()) })
    }).collect();
    json!({ "screens": list, "monitor": st.monitor, "side": if st.side == "left" { "left" } else { "right" } })
}

#[tauri::command]
fn set_placement(app: AppHandle, monitor: String, side: String) {
    let mut st = store::load_settings();
    st.monitor = monitor.chars().take(200).collect();
    st.side = if side == "left" { "left".into() } else { "right".into() };
    store::save_settings(&st);
    keep_open(&app, 1500);
    if let Some(w) = overlay(&app) { place(&w); let _ = w.set_focus(); }
    repaint(&app);
    let _ = app.emit("state-changed", ());
}

#[tauri::command]
fn set_pinned(state: tauri::State<AppState>, on: bool) { state.0.lock().unwrap().pinned = on; }

#[tauri::command]
fn open_external(app: AppHandle, url: String) {
    if url.starts_with("https://") { keep_open(&app, 1500); let _ = app.opener().open_url(url, None::<&str>); }
}

#[tauri::command]
fn sign_out(app: AppHandle) {
    store::set_token(None);
    { let mut s = lk(&app); s.uploader_status.clear(); s.web_signed = false; }
    store::log("signed out");
    let _ = app.emit("state-changed", ());
}

#[tauri::command]
async fn start_signin(app: AppHandle) -> Result<String, String> {
    let existing = lk(&app).linking.as_ref().map(|l| l.0.clone());
    if let Some(code) = existing {
        let _ = app.opener().open_url(format!("{SITE}/link/{code}"), None::<&str>);
        return Ok(code);
    }
    let (st, v) = api("POST", "/api/app/link/start", Some(json!({ "device": device_name() })), false).await?;
    let code = v.get("code").and_then(|c| c.as_str()).ok_or_else(|| v.get("error").and_then(|e| e.as_str()).unwrap_or("couldn't start signing in").to_string())?.to_string();
    let secret = v.get("secret").and_then(|c| c.as_str()).unwrap_or_default().to_string();
    let secs = v.get("expiresIn").and_then(|x| x.as_u64()).unwrap_or(600);
    if st != 200 { return Err("couldn't start signing in".into()); }
    lk(&app).linking = Some((code.clone(), secret.clone(), Instant::now() + Duration::from_secs(secs)));
    store::log(&format!("sign-in started, code {code}"));
    let a2 = app.clone();
    tauri::async_runtime::spawn(async move {
        loop {
            tokio::time::sleep(Duration::from_secs(3)).await;
            let expired = lk(&a2).linking.as_ref().map(|l| Instant::now() > l.2).unwrap_or(true);
            if expired { lk(&a2).linking = None; break; }
            if let Ok((_, v)) = api("POST", "/api/app/link/poll", Some(json!({ "secret": secret })), false).await {
                match v.get("status").and_then(|s| s.as_str()) {
                    Some("approved") => {
                        if let Some(t) = v.get("token").and_then(|t| t.as_str()) {
                            store::set_token(Some(t));
                            let mut st = store::load_settings();
                            st.display = v.get("display").and_then(|d| d.as_str()).unwrap_or_default().to_string();
                            store::save_settings(&st);
                            lk(&a2).linking = None;
                            store::log(&format!("signed in as {}", st.display));
                            go_home(a2.clone());
                            show(&a2);
                            say(&a2, "ONN Companion", &format!("Signed in as {}. Census uploads and guild notices are on.", st.display));
                            heartbeat(&a2).await;
                        }
                        break;
                    }
                    Some("expired") => { lk(&a2).linking = None; break; }
                    _ => {}
                }
            }
        }
        let _ = a2.emit("state-changed", ());
    });
    // Approve in your normal web browser (where you're usually already signed in to ONN).
    let _ = app.opener().open_url(format!("{SITE}/link/{code}"), None::<&str>);
    let _ = app.emit("state-changed", ());
    Ok(code)
}

#[tauri::command]
fn cancel_signin(app: AppHandle) {
    lk(&app).linking = None;
    let _ = app.emit("state-changed", ());
}

#[tauri::command]
async fn send_census_now(app: AppHandle) {
    {
        let mut s = lk(&app);
        s.hold_until = None;
        s.last_scan = None;
    }
    let mut up = store::load_uploads();
    up.stamps.clear();
    store::save_uploads(&up);
    census_tick(&app).await;
}

// ---------- background work ----------
fn stamp(p: &PathBuf) -> Option<String> {
    let m = std::fs::metadata(p).ok()?;
    let t = m.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
    Some(format!("{}:{}", t.as_millis(), m.len()))
}

async fn census_tick(app: &AppHandle) {
    let custom = store::load_settings().wow_path;
    let files = {
        let rescan = lk(&app).last_scan.map(|t| t.elapsed() > Duration::from_secs(600)).unwrap_or(true);
        if rescan {
            let found = paths::saved_files(Some(&custom));
            let mut s = lk(&app);
            s.files = found;
            s.last_scan = Some(Instant::now());
        }
        let s = lk(&app);
        if s.hold_until.map(|t| Instant::now() < t).unwrap_or(false) { return; }
        s.files.clone()
    };
    let Some(token) = store::get_token() else { return };
    let mut up = store::load_uploads();
    for f in files {
        let Some(st) = stamp(&f) else { continue };
        let key = f.to_string_lossy().to_string();
        if up.stamps.get(&key) == Some(&st) { continue; }
        // WoW writes the file at logout; wait until its size stops changing.
        let mut last = 0u64;
        for _ in 0..10 {
            let now = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
            if now == last && now > 0 { break; }
            last = now;
            tokio::time::sleep(Duration::from_secs(2)).await;
        }
        let Ok(bytes) = tokio::fs::read(&f).await else { continue };
        let res = client().post(format!("{SITE}/api/census")).bearer_auth(&token).header("x-onn-source", device_name())
            .header("content-type", "text/plain; charset=utf-8").body(bytes).timeout(Duration::from_secs(180)).send().await;
        match res {
            Ok(r) => {
                let code = r.status().as_u16();
                let v = r.json::<Value>().await.unwrap_or(Value::Null);
                let mut s = lk(&app);
                match code {
                    200 => {
                        up.stamps.insert(key.clone(), stamp(&f).unwrap_or(st));
                        up.last_upload = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
                        s.last_error.clear();
                        store::log(&format!("uploaded {key}: {} guild reports", v.get("guilds").and_then(|x| x.as_i64()).unwrap_or(0)));
                    }
                    403 if v.get("status").and_then(|x| x.as_str()) == Some("pending") => {
                        s.uploader_status = "pending".into();
                        s.hold_until = Some(Instant::now() + Duration::from_secs(600));
                    }
                    401 => { drop(s); store::set_token(None); let _ = app.emit("state-changed", ()); return; }
                    429 => { s.hold_until = Some(Instant::now() + Duration::from_secs(60)); }
                    _ => {
                        s.last_error = v.get("error").and_then(|e| e.as_str()).unwrap_or("upload failed").to_string();
                        s.hold_until = Some(Instant::now() + Duration::from_secs(120));
                        store::log(&format!("upload failed for {key}: {}", s.last_error));
                    }
                }
            }
            Err(e) => {
                let mut s = lk(&app);
                s.last_error = e.to_string();
                s.hold_until = Some(Instant::now() + Duration::from_secs(120));
            }
        }
        store::save_uploads(&up);
        break; // one file per tick
    }
}

async fn heartbeat(app: &AppHandle) {
    if store::get_token().is_none() { return; }
    let (files, last_error) = { let s = lk(&app); (s.files.len(), s.last_error.clone()) };
    let body = json!({ "version": VERSION, "wowFound": files > 0, "files": files, "lastUpload": store::load_uploads().last_upload, "lastError": last_error, "uploads": true, "os": std::env::consts::OS });
    let Ok((code, v)) = api("POST", "/api/app/status", Some(body), true).await else { return };
    if code == 401 { store::set_token(None); let _ = app.emit("state-changed", ()); return; }
    if code != 200 { return; }
    if let Some(d) = v.get("display").and_then(|d| d.as_str()) {
        let mut st = store::load_settings();
        if st.display != d { st.display = d.to_string(); store::save_settings(&st); }
    }
    let status = v.pointer("/uploader/status").and_then(|s| s.as_str()).unwrap_or_default().to_string();
    let mut notes: Vec<(String, String)> = Vec::new();
    {
        let mut s = lk(&app);
        if s.uploader_status == "pending" && status == "approved" {
            s.hold_until = None;
            notes.push(("ONN Companion".into(), "Your census uploads were approved. Thanks for keeping the Army page current!".into()));
        }
        s.uploader_status = status;
        if let Some(evs) = v.pointer("/notices/eventsSoon").and_then(|e| e.as_array()) {
            for e in evs {
                let id = e.get("id").and_then(|x| x.as_str()).unwrap_or_default().to_string();
                if id.is_empty() || !s.notified.insert(id) { continue; }
                let title = e.get("title").and_then(|x| x.as_str()).unwrap_or("Guild event");
                let guild = e.get("guild").and_then(|x| x.as_str()).unwrap_or("");
                let when = e.get("startsAt").and_then(|x| x.as_str()).and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
                    .map(|t| t.with_timezone(&chrono::Local).format("%-I:%M %p").to_string()).unwrap_or_default();
                notes.push((format!("Coming up: {title}"), format!("{guild} - {when}")));
                break;
            }
        }
        let joins = v.pointer("/notices/joinRequests").and_then(|x| x.as_i64()).unwrap_or(0);
        if s.joins_seen >= 0 && joins > s.joins_seen && s.joins_seen != 0 {
            notes.push(("Guild Spaces".into(), format!("{joins} waiting to join your guild space")));
        }
        s.joins_seen = joins;
    }
    for (t, b) in notes { say(app, &t, &b); }
    let _ = app.emit("state-changed", ());
}

fn register_hotkey(app: &AppHandle) {
    let mut tries: Vec<String> = Vec::new();
    let custom = store::load_settings().hotkey;
    if !custom.is_empty() { tries.push(custom); }
    for t in ["Ctrl+Shift+O", "Alt+Shift+O", "Ctrl+Alt+O", "Ctrl+Shift+F10"] { tries.push(t.to_string()); }
    for spec in tries {
        let Ok(sc) = spec.replace("Ctrl", "Control").parse::<Shortcut>() else { continue };
        if app.global_shortcut().register(sc).is_ok() {
            store::log(&format!("overlay hotkey {spec}"));
            lk(&app).hotkey = spec;
            return;
        }
        store::log(&format!("hotkey {spec} is taken"));
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let hidden_start = std::env::args().any(|a| a == "--hidden");
    tauri::Builder::default()
        .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show(app)))
        .plugin(tauri_plugin_notification::init())
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_updater::Builder::new().build())
        .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--hidden"])))
        .plugin(
            tauri_plugin_global_shortcut::Builder::new()
                .with_handler(|app, _sc, ev| { if ev.state() == ShortcutState::Pressed { toggle(app) } })
                .build(),
        )
        .manage(AppState(Mutex::new(Inner { joins_seen: -1, ..Default::default() })))
        .invoke_handler(tauri::generate_handler![app_info, feed, supply_action, rsvp, open_page, go_home, hide_overlay, overlay_ack, check_for_updates, install_update, screens, set_placement, set_pinned, open_external, sign_out, start_signin, cancel_signin, send_census_now])
        .setup(move |app| {
            let handle = app.handle().clone();
            // The panel: frameless, see-through, always on top, hidden from the taskbar.
            let nav_app = handle.clone();
            let win_app = handle.clone();
            let w = WebviewWindowBuilder::new(app, "overlay", WebviewUrl::App("index.html".into()))
                .title("ONN Companion")
                .inner_size(PANEL_W, 820.0)
                .resizable(false)
                .decorations(false)
                .transparent(true)
                .always_on_top(true)
                .skip_taskbar(true)
                .shadow(false)
                .visible(false)
                // Keep drawing while hidden so the panel is never blank when it comes back.
                .background_throttling(tauri::utils::config::BackgroundThrottlingPolicy::Disabled)
                .initialization_script(PAGE_BAR)
                .on_navigation(move |u| {
                    keep_open(&nav_app, 1500);
                    if is_local(u) || is_ours(u) || is_signin(u) || u.scheme() == "about" {
                        let seq = { let mut s = lk(&nav_app); s.nav_seq += 1; s.nav_seq };
                        watch_nav(nav_app.clone(), seq);
                        return true;
                    }
                    let _ = nav_app.opener().open_url(u.as_str(), None::<&str>); // other sites: your browser
                    // If this came in the middle of a redirect (the page before it never finished),
                    // the panel would be left on an empty page: go back home instead.
                    let mid_redirect = { let s = lk(&nav_app); s.loaded_nav < s.nav_seq };
                    if mid_redirect {
                        let a = nav_app.clone();
                        tauri::async_runtime::spawn(async move {
                            reset_panel(&a);
                            tokio::time::sleep(Duration::from_millis(600)).await;
                            let _ = a.emit("say", json!({ "text": "That page opened in your web browser." }));
                        });
                    }
                    false
                })
                // Links that would open a new window: ONN pages stay in the panel, other sites go to your browser.
                .on_new_window(move |u, _features| {
                    keep_open(&win_app, 1500);
                    if is_ours(&u) || is_signin(&u) {
                        let a2 = win_app.clone();
                        tauri::async_runtime::spawn(async move { if let Some(w) = overlay(&a2) { let _ = w.navigate(u); } });
                    } else {
                        let _ = win_app.opener().open_url(u.as_str(), None::<&str>);
                    }
                    tauri::webview::NewWindowResponse::Deny
                })
                .build()?;
            // The panel's own home page. Don't read it from the window here: on Windows the page
            // hasn't loaded yet at this point and reports about:blank, so "home" pointed at an
            // empty page and the see-through panel went invisible after sign-in, Back or Home.
            lk(&handle).home = local_home();
            let h2 = handle.clone();
            w.on_window_event(move |e| {
                if let WindowEvent::Focused(false) = e {
                    // Only close when you've really clicked into another app: skip our own page loads
                    // and link opens, and re-check a moment later (web views can blink focus).
                    let skip = { let s = lk(&h2); s.pinned || !s.visible || s.grace_until.map(|t| Instant::now() < t).unwrap_or(false) };
                    if skip { return; }
                    let h3 = h2.clone();
                    tauri::async_runtime::spawn(async move {
                        tokio::time::sleep(Duration::from_millis(350)).await;
                        let still_away = overlay(&h3).and_then(|w| w.is_focused().ok()).map(|f| !f).unwrap_or(true);
                        let skip = { let s = lk(&h3); s.pinned || !s.visible || s.grace_until.map(|t| Instant::now() < t).unwrap_or(false) };
                        if still_away && !skip { hide(&h3); }
                    });
                }
            });

            // Tray icon: click toggles the panel, right-click for the menu.
            let show_i = MenuItem::with_id(app, "show", "Show overlay", true, None::<&str>)?;
            let send_i = MenuItem::with_id(app, "send", "Send census now", true, None::<&str>)?;
            let reset_i = MenuItem::with_id(app, "reset", "Reset overlay", true, None::<&str>)?;
            let upd_i = MenuItem::with_id(app, "update", "Check for updates", true, None::<&str>)?;
            let log_i = MenuItem::with_id(app, "log", "Open log folder", true, None::<&str>)?;
            let out_i = MenuItem::with_id(app, "signout", "Sign out", true, None::<&str>)?;
            let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
            let sep = PredefinedMenuItem::separator(app)?;
            let sep2 = PredefinedMenuItem::separator(app)?;
            let menu = Menu::with_items(app, &[&show_i, &reset_i, &sep, &send_i, &upd_i, &log_i, &out_i, &sep2, &quit_i])?;
            let mut tray = TrayIconBuilder::with_id("main").tooltip("ONN Companion").menu(&menu).show_menu_on_left_click(false)
                .on_menu_event(|app, ev| match ev.id().as_ref() {
                    "show" => show(app),
                    "reset" => { reset_panel(app); show(app); }
                    "update" => {
                        let a = app.clone();
                        tauri::async_runtime::spawn(async move {
                            if lk(&a).upd_state == "ready" { let _ = install_pending(&a); } else { check_updates(&a, true).await; }
                        });
                    }
                    "send" => { let a = app.clone(); tauri::async_runtime::spawn(async move { send_census_now(a).await }); }
                    "log" => { let _ = app.opener().open_path(store::data_dir().to_string_lossy().to_string(), None::<&str>); }
                    "signout" => sign_out(app.clone()),
                    "quit" => app.exit(0),
                    _ => {}
                })
                .on_tray_icon_event(|tray, ev| {
                    if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = ev { toggle(tray.app_handle()) }
                });
            if let Some(icon) = app.default_window_icon() { tray = tray.icon(icon.clone()); }
            tray.build(app)?;

            register_hotkey(&handle);

            // Start with the computer (first run only turns it on; people can turn it off later).
            {
                use tauri_plugin_autostart::ManagerExt;
                let mut st = store::load_settings();
                if !st.intro_shown {
                    let _ = app.autolaunch().enable();
                    st.intro_shown = true;
                    store::save_settings(&st);
                }
            }

            // Census every 20 seconds, check-in every 5 minutes.
            let a = handle.clone();
            tauri::async_runtime::spawn(async move {
                let mut n: u64 = 0;
                loop {
                    census_tick(&a).await;
                    if n % 15 == 0 { heartbeat(&a).await; }
                    if n == 3 { check_updates(&a, false).await; } // first check a minute after starting
                    update_tick(&a).await;
                    n += 1;
                    tokio::time::sleep(Duration::from_secs(20)).await;
                }
            });

            store::log(&format!("ONN Companion {VERSION} started on {}", std::env::consts::OS));
            if !hidden_start || store::get_token().is_none() { show(&handle); }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running ONN Companion");
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn hotkeys_parse() {
        for spec in ["Ctrl+Shift+O", "Alt+Shift+O", "Ctrl+Alt+O", "Ctrl+Shift+F10"] {
            assert!(spec.replace("Ctrl", "Control").parse::<Shortcut>().is_ok(), "{spec}");
        }
    }
    #[test]
    fn finds_wine_saved_files() {
        let home = std::env::temp_dir().join("onn-test-home");
        let f = home.join("Games/battlenet/drive_c/Program Files (x86)/World of Warcraft/_classic_/WTF/Account/ABC/SavedVariables");
        std::fs::create_dir_all(&f).unwrap();
        std::fs::write(f.join("Olympus.lua"), "OlympusDB = {}").unwrap();
        std::env::set_var("HOME", &home);
        let found = paths::saved_files(None);
        assert!(found.iter().any(|p| p.ends_with("ABC/SavedVariables/Olympus.lua")), "{found:?}");
    }
    #[test]
    fn safe_paths() {
        assert_eq!(safe_path("/army"), "/army");
        assert_eq!(safe_path("//evil.com"), "/app");
        assert_eq!(safe_path("javascript:alert(1)"), "/app");
    }
}
src-tauri/src/paths.rs126 lines
// Copyright (c) 2026 Archway Point. All rights reserved.
// Finds the Olympus Guild addon's saved file (WTF/Account/<ACCOUNT>/SavedVariables/Olympus.lua)
// wherever WoW is installed: normal Windows and macOS installs, and on Linux inside Wine,
// Lutris, Bottles or Steam (Proton) prefixes.
#[allow(unused_imports)]
use std::path::{Path, PathBuf};

fn push_if_dir(v: &mut Vec<PathBuf>, p: PathBuf) {
    if p.is_dir() && !v.contains(&p) {
        v.push(p);
    }
}

/// Folders that might be a "World of Warcraft" install.
pub fn wow_roots(custom: Option<&str>) -> Vec<PathBuf> {
    let mut roots: Vec<PathBuf> = Vec::new();
    if let Some(c) = custom {
        if !c.trim().is_empty() {
            push_if_dir(&mut roots, PathBuf::from(c.trim()));
        }
    }
    #[cfg(windows)]
    {
        use winreg::enums::HKEY_LOCAL_MACHINE;
        use winreg::RegKey;
        for key in [
            r"SOFTWARE\WOW6432Node\Blizzard Entertainment\World of Warcraft",
            r"SOFTWARE\Blizzard Entertainment\World of Warcraft",
        ] {
            if let Ok(k) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(key) {
                if let Ok(p) = k.get_value::<String, _>("InstallPath") {
                    let p = PathBuf::from(p.trim_end_matches('\\'));
                    if let Some(parent) = p.parent() {
                        push_if_dir(&mut roots, parent.to_path_buf());
                    }
                    push_if_dir(&mut roots, p);
                }
            }
        }
        for letter in b'C'..=b'Z' {
            let drive = format!("{}:\\", letter as char);
            if !Path::new(&drive).exists() {
                continue;
            }
            for sub in [
                r"Program Files (x86)\World of Warcraft",
                r"Program Files\World of Warcraft",
                r"World of Warcraft",
                r"Games\World of Warcraft",
                r"Blizzard\World of Warcraft",
            ] {
                push_if_dir(&mut roots, Path::new(&drive).join(sub));
            }
        }
    }
    #[cfg(target_os = "macos")]
    {
        push_if_dir(&mut roots, PathBuf::from("/Applications/World of Warcraft"));
        if let Some(h) = dirs::home_dir() {
            push_if_dir(&mut roots, h.join("Applications/World of Warcraft"));
        }
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        if let Some(h) = dirs::home_dir() {
            // Every Wine-style prefix we know of; each has its own drive_c.
            let mut prefixes: Vec<PathBuf> = vec![h.join(".wine")];
            let scan = |dir: PathBuf, deep: &str, out: &mut Vec<PathBuf>| {
                if let Ok(rd) = std::fs::read_dir(&dir) {
                    for e in rd.flatten() {
                        let p = if deep.is_empty() { e.path() } else { e.path().join(deep) };
                        if p.is_dir() {
                            out.push(p);
                        }
                    }
                }
            };
            scan(h.join("Games"), "", &mut prefixes); // Lutris default
            scan(h.join(".local/share/lutris/runners/wine"), "", &mut prefixes);
            scan(h.join(".local/share/bottles/bottles"), "", &mut prefixes);
            scan(h.join(".var/app/com.usebottles.bottles/data/bottles/bottles"), "", &mut prefixes);
            scan(h.join(".local/share/Steam/steamapps/compatdata"), "pfx", &mut prefixes);
            scan(h.join(".steam/steam/steamapps/compatdata"), "pfx", &mut prefixes);
            scan(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata"), "pfx", &mut prefixes);
            for pre in prefixes {
                for sub in [
                    "drive_c/Program Files (x86)/World of Warcraft",
                    "drive_c/Program Files/World of Warcraft",
                    "drive_c/World of Warcraft",
                    "World of Warcraft",
                ] {
                    push_if_dir(&mut roots, pre.join(sub));
                }
            }
        }
    }
    roots
}

/// Every Olympus.lua under those installs (each game version folder has its own WTF folder).
pub fn saved_files(custom: Option<&str>) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    for root in wow_roots(custom) {
        let mut bases = vec![root.clone()];
        if let Ok(rd) = std::fs::read_dir(&root) {
            for e in rd.flatten() {
                if e.path().is_dir() {
                    bases.push(e.path());
                }
            }
        }
        for base in bases {
            let acct = base.join("WTF").join("Account");
            if let Ok(rd) = std::fs::read_dir(&acct) {
                for e in rd.flatten() {
                    let f = e.path().join("SavedVariables").join("Olympus.lua");
                    if f.is_file() && !out.contains(&f) {
                        out.push(f);
                    }
                }
            }
        }
    }
    out
}
src-tauri/src/store.rs108 lines
// Copyright (c) 2026 Archway Point. All rights reserved.
// Settings, upload history and the sign-in token. The token goes in the system's own secure
// store (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux); if that
// isn't available it falls back to a file only this user can read.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

const SERVICE: &str = "ONN Companion";
const ACCOUNT: &str = "app-token";

#[derive(Serialize, Deserialize, Default, Clone)]
pub struct Settings {
    #[serde(default)]
    pub display: String,
    #[serde(default)]
    pub hotkey: String,
    #[serde(default, rename = "wowPath")]
    pub wow_path: String,
    #[serde(default, rename = "introShown")]
    pub intro_shown: bool,
    /// Which screen the panel opens on: "" = the one your mouse is on, otherwise that screen's name.
    #[serde(default)]
    pub monitor: String,
    /// "right" (default) or "left" edge of the screen.
    #[serde(default)]
    pub side: String,
}

#[derive(Serialize, Deserialize, Default, Clone)]
pub struct UploadState {
    #[serde(default)]
    pub stamps: HashMap<String, String>,
    #[serde(default, rename = "lastUpload")]
    pub last_upload: String,
}

pub fn data_dir() -> PathBuf {
    let d = dirs::data_local_dir().unwrap_or_else(std::env::temp_dir).join("ONN Companion");
    let _ = std::fs::create_dir_all(&d);
    d
}

fn read_json<T: for<'de> Deserialize<'de> + Default>(name: &str) -> T {
    std::fs::read_to_string(data_dir().join(name)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn write_json<T: Serialize>(name: &str, v: &T) {
    if let Ok(s) = serde_json::to_string_pretty(v) {
        let tmp = data_dir().join(format!("{name}.tmp"));
        if std::fs::write(&tmp, s).is_ok() {
            let _ = std::fs::rename(&tmp, data_dir().join(name));
        }
    }
}
pub fn load_settings() -> Settings { read_json("settings.json") }
pub fn save_settings(s: &Settings) { write_json("settings.json", s) }
pub fn load_uploads() -> UploadState { read_json("state.json") }
pub fn save_uploads(s: &UploadState) { write_json("state.json", s) }

fn token_file() -> PathBuf { data_dir().join("token") }

pub fn get_token() -> Option<String> {
    if let Ok(e) = keyring::Entry::new(SERVICE, ACCOUNT) {
        if let Ok(t) = e.get_password() {
            if !t.is_empty() {
                return Some(t);
            }
        }
    }
    std::fs::read_to_string(token_file()).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}

pub fn set_token(t: Option<&str>) {
    let entry = keyring::Entry::new(SERVICE, ACCOUNT).ok();
    match t {
        Some(t) => {
            let saved = entry.as_ref().map(|e| e.set_password(t).is_ok()).unwrap_or(false);
            if saved {
                let _ = std::fs::remove_file(token_file());
            } else {
                let _ = std::fs::write(token_file(), t);
                #[cfg(unix)]
                {
                    use std::os::unix::fs::PermissionsExt;
                    let _ = std::fs::set_permissions(token_file(), std::fs::Permissions::from_mode(0o600));
                }
            }
        }
        None => {
            if let Some(e) = entry {
                let _ = e.delete_credential();
            }
            let _ = std::fs::remove_file(token_file());
        }
    }
}

pub fn log(msg: &str) {
    use std::io::Write;
    let p = data_dir().join("companion.log");
    if std::fs::metadata(&p).map(|m| m.len() > 512 * 1024).unwrap_or(false) {
        let _ = std::fs::rename(&p, data_dir().join("companion.log.old"));
    }
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
        let _ = writeln!(f, "{}  {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
    }
}
src-tauri/src/main.rs8 lines
// Copyright (c) 2026 Archway Point. All rights reserved.
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    onn_companion_lib::run()
}
src-tauri/Cargo.toml43 lines
[package]
name = "onn-companion"
version = "3.0.0-alpha.7"
description = "ONN Companion: the Olympus overlay for WoW: Forever"
authors = ["Archway Point"]
license-file = "../LICENSE.txt"
edition = "2021"
publish = false

[lib]
name = "onn_companion_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["time", "sync", "fs"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
dirs = "5"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
urlencoding = "2"
tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls", "zip"] }

[target.'cfg(windows)'.dependencies]
winreg = "0.52"

[profile.release]
lto = true
codegen-units = 1
opt-level = "s"
strip = true
panic = "abort"
src-tauri/build.rs12 lines
// Copyright (c) 2026 Archway Point. All rights reserved.
fn main() {
    // Listing the app's commands turns them into permissions, so web pages shown inside the
    // overlay can be limited to "go back" and "close" (see capabilities/).
    let cmds = &[
        "app_info", "feed", "supply_action", "rsvp", "open_page", "go_home", "hide_overlay","overlay_ack","check_for_updates","install_update","screens","set_placement",
        "set_pinned", "open_external", "sign_out", "start_signin", "cancel_signin", "send_census_now",
    ];
    tauri_build::try_build(tauri_build::Attributes::new().app_manifest(tauri_build::AppManifest::new().commands(cmds)))
        .expect("failed to run tauri-build");
}
src-tauri/tauri.conf.json65 lines
{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "ONN Companion",
  "version": "3.0.0-alpha.7",
  "identifier": "com.archwaypoint.onncompanion",
  "build": {
    "frontendDist": "../src"
  },
  "app": {
    "withGlobalTauri": true,
    "windows": [],
    "security": {
      "csp": null,
      "capabilities": [
        "overlay-local",
        "overlay-remote"
      ]
    }
  },
  "bundle": {
    "active": true,
    "targets": [
      "nsis",
      "deb",
      "appimage",
      "dmg"
    ],
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.ico",
      "icons/icon.png"
    ],
    "publisher": "Archway Point",
    "copyright": "Copyright (c) 2026 Archway Point. All rights reserved.",
    "category": "Game",
    "shortDescription": "The Olympus overlay for WoW: Forever",
    "longDescription": "ONN Companion shows your guild events, supply runs, guild boards and the Olympus census in an overlay over WoW: Forever, and sends the Olympus Guild addon's census to olympusnewsnetwork.com.",
    "licenseFile": "../LICENSE.txt",
    "windows": {
      "nsis": {
        "installMode": "currentUser",
        "displayLanguageSelector": false
      }
    },
    "linux": {
      "deb": {
        "depends": []
      }
    },
    "createUpdaterArtifacts": true
  },
  "plugins": {
    "updater": {
      "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQyQjBFOEQ4NDRCMjhEOTQKUldTVWpiSkUyT2l3UWhSdFNzOXpIQ1IwVWJKeldOK0hjNEYvdS9jYUdIMXJNZjFJdC9tRUtNcFgK",
      "endpoints": [
        "https://olympusnewsnetwork.com/companion/update/{{target}}/{{arch}}/{{current_version}}"
      ],
      "windows": {
        "installMode": "quiet"
      }
    }
  }
}
src-tauri/capabilities/overlay-local.json11 lines
{
  "identifier": "overlay-local",
  "description": "The overlay's own screens",
  "windows": ["overlay"],
  "permissions": [
    "core:default", "notification:default", "opener:default",
    "allow-app-info", "allow-feed", "allow-supply-action", "allow-rsvp", "allow-open-page", "allow-go-home",
    "allow-hide-overlay", "allow-overlay-ack", "allow-check-for-updates", "allow-install-update", "allow-screens", "allow-set-placement", "allow-set-pinned", "allow-open-external", "allow-sign-out", "allow-start-signin", "allow-cancel-signin", "allow-send-census-now"
  ]
}
src-tauri/capabilities/overlay-remote.json8 lines
{
  "identifier": "overlay-remote",
  "description": "ONN and Archway ID pages shown inside the overlay may only go back to the overlay or close it",
  "windows": ["overlay"],
  "remote": { "urls": ["https://olympusnewsnetwork.com/*", "https://www.olympusnewsnetwork.com/*", "https://archwaypoint.com/*", "https://*.archwaypoint.com/*"] },
  "permissions": ["allow-go-home", "allow-hide-overlay", "allow-overlay-ack"]
}

Download the source for review (.zip, 219 KB)

SHA-256 of the source zip: 85f87b8a34e7689fb0d7de05a636fea8a93cf667065e13c544094e0ae8665d3e
SHA-256 of the Windows .zip: 636079f7d92b7b55cf1b4faf906a611b18b0ec84b72fcdd7845295d151e8d11e
SHA-256 of the Windows installer (.exe): 0400322e49727cbb5bb277a5b10e90355b71dff88a5122c2ccc95495de8837a5
SHA-256 of the Package (.deb): 205903b1fa17430557b7f439f5944d9a56af0c6dcdb34e95491b5943814f5e45
SHA-256 of the AppImage: 6053d747e309ea85c58cb8908fecf86de3be8b702ea268e1e14c0380fd61182f

Older Windows version

ONN Companion 2.x, the PowerShell script version for Windows, still works and keeps updating itself. Download it (.zip, 1069 KB) if the new app doesn't run on your PC.