From b5bc479245a58f4ce82236cc5ac882e6847d51ff Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sat, 9 May 2026 17:55:58 -0700 Subject: [PATCH] docs(site): add Peekaboo-inspired documentation site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static docs site under docs/site/, generated by a small Python script (no Jekyll, no Astro, no node_modules). Inspired by peekaboo.sh — same shell (sidebar + content + right-rail TOC), same typography (Fraunces + Inter + JetBrains Mono), Optune palette (macOS system blue accent + deep purple secondary). Layout - docs/site/build.py — markdown -> HTML generator - docs/site/_template.html — shared shell - docs/site/_assets/style.css — full design system, light/dark themes - docs/site/_assets/site.js — theme toggle, copy buttons, TOC scroll-spy - docs/site/pages/*.md — 20 content pages - docs/site/dist/ — generator output (gitignored) Pages - Start: Overview, Install, Quickstart, Permissions & TCC - Features: Battery, Pointer & DPI, Wheel & SmartShift, Buttons, Hosts, Onboard, Keyboard, Per-app profiles - Reference: CLI, Architecture, Adding a device, Building, Troubleshooting, HID++ primer - About: Roadmap, Acknowledgements Deploy - .github/workflows/docs.yml runs build.py on every push to main and publishes docs/site/dist via GitHub Pages. No content screenshots yet — that's a follow-up pass. --- .github/workflows/docs.yml | 44 ++++ docs/site/.gitignore | 4 + docs/site/README.md | 30 +++ docs/site/_assets/favicon.svg | 13 + docs/site/_assets/site.js | 103 ++++++++ docs/site/_assets/style.css | 238 ++++++++++++++++++ docs/site/_template.html | 78 ++++++ docs/site/build.py | 376 ++++++++++++++++++++++++++++ docs/site/pages/acknowledgements.md | 35 +++ docs/site/pages/adding-a-device.md | 85 +++++++ docs/site/pages/architecture.md | 99 ++++++++ docs/site/pages/battery.md | 80 ++++++ docs/site/pages/building.md | 102 ++++++++ docs/site/pages/buttons.md | 70 ++++++ docs/site/pages/cli.md | 139 ++++++++++ docs/site/pages/hidpp.md | 77 ++++++ docs/site/pages/hosts.md | 56 +++++ docs/site/pages/index.md | 53 ++++ docs/site/pages/install.md | 60 +++++ docs/site/pages/keyboard.md | 52 ++++ docs/site/pages/onboard.md | 58 +++++ docs/site/pages/permissions.md | 52 ++++ docs/site/pages/pointer.md | 64 +++++ docs/site/pages/profiles.md | 85 +++++++ docs/site/pages/quickstart.md | 54 ++++ docs/site/pages/roadmap.md | 42 ++++ docs/site/pages/troubleshooting.md | 61 +++++ docs/site/pages/wheel.md | 50 ++++ 28 files changed, 2260 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/site/.gitignore create mode 100644 docs/site/README.md create mode 100644 docs/site/_assets/favicon.svg create mode 100644 docs/site/_assets/site.js create mode 100644 docs/site/_assets/style.css create mode 100644 docs/site/_template.html create mode 100644 docs/site/build.py create mode 100644 docs/site/pages/acknowledgements.md create mode 100644 docs/site/pages/adding-a-device.md create mode 100644 docs/site/pages/architecture.md create mode 100644 docs/site/pages/battery.md create mode 100644 docs/site/pages/building.md create mode 100644 docs/site/pages/buttons.md create mode 100644 docs/site/pages/cli.md create mode 100644 docs/site/pages/hidpp.md create mode 100644 docs/site/pages/hosts.md create mode 100644 docs/site/pages/index.md create mode 100644 docs/site/pages/install.md create mode 100644 docs/site/pages/keyboard.md create mode 100644 docs/site/pages/onboard.md create mode 100644 docs/site/pages/permissions.md create mode 100644 docs/site/pages/pointer.md create mode 100644 docs/site/pages/profiles.md create mode 100644 docs/site/pages/quickstart.md create mode 100644 docs/site/pages/roadmap.md create mode 100644 docs/site/pages/troubleshooting.md create mode 100644 docs/site/pages/wheel.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f20ef72 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,44 @@ +name: Docs site + +on: + push: + branches: [main] + paths: + - "docs/site/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build site + working-directory: docs/site + run: python build.py + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/site/.gitignore b/docs/site/.gitignore new file mode 100644 index 0000000..f60ee09 --- /dev/null +++ b/docs/site/.gitignore @@ -0,0 +1,4 @@ +dist/ +__pycache__/ +*.pyc +.venv/ diff --git a/docs/site/README.md b/docs/site/README.md new file mode 100644 index 0000000..88d3f7f --- /dev/null +++ b/docs/site/README.md @@ -0,0 +1,30 @@ +# Optune docs site + +Static documentation site, generated by `build.py` from Markdown content under `pages/`. +Inspired by [peekaboo.sh](https://peekaboo.sh) — same shell, our content and palette. + +## Build + +```bash +cd docs/site +python3 build.py +open dist/index.html +``` + +## Add a page + +1. Create `pages/.md` with frontmatter: + ``` + --- + title: My Page + section: GUIDES + order: 10 + description: Short SEO blurb. + --- + # My Page + ``` +2. Re-run `build.py`. The sidebar regenerates automatically from `section` + `order`. + +## Deploy + +CI publishes `dist/` to GitHub Pages on every push to `main` that touches `docs/site/**`. diff --git a/docs/site/_assets/favicon.svg b/docs/site/_assets/favicon.svg new file mode 100644 index 0000000..8ac9998 --- /dev/null +++ b/docs/site/_assets/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/site/_assets/site.js b/docs/site/_assets/site.js new file mode 100644 index 0000000..8602e35 --- /dev/null +++ b/docs/site/_assets/site.js @@ -0,0 +1,103 @@ +// Optune docs site — Peekaboo-inspired interactions. + +(() => { + // Theme toggle + const root = document.documentElement; + const toggle = document.querySelector('[data-theme-toggle]'); + const label = toggle?.querySelector('.theme-toggle__label'); + const setLabel = () => { + if (!label) return; + label.textContent = root.dataset.theme === 'light' ? 'Light' : 'Dark'; + }; + setLabel(); + toggle?.addEventListener('click', () => { + const next = root.dataset.theme === 'light' ? 'dark' : 'light'; + root.dataset.theme = next; + try { localStorage.setItem('optune-theme', next); } catch {} + setLabel(); + }); + + // Sidebar mobile + const navToggle = document.querySelector('.nav-toggle'); + const sidebar = document.querySelector('.sidebar'); + navToggle?.addEventListener('click', () => { + const open = sidebar.classList.toggle('open'); + navToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + document.addEventListener('click', (e) => { + if (!sidebar?.classList.contains('open')) return; + if (sidebar.contains(e.target) || navToggle.contains(e.target)) return; + sidebar.classList.remove('open'); + navToggle.setAttribute('aria-expanded', 'false'); + }); + + // Sidebar search filter + const search = document.querySelector('.search input'); + const navLinks = Array.from(document.querySelectorAll('.nav-link')); + search?.addEventListener('input', () => { + const q = search.value.trim().toLowerCase(); + navLinks.forEach(a => { + const match = !q || a.textContent.toLowerCase().includes(q); + a.style.display = match ? '' : 'none'; + }); + // Hide section headings that have no visible children + document.querySelectorAll('nav section').forEach(sec => { + const visible = Array.from(sec.querySelectorAll('.nav-link')).some(a => a.style.display !== 'none'); + sec.style.display = visible ? '' : 'none'; + }); + }); + + // Copy buttons on pre blocks + document.querySelectorAll('.doc pre').forEach(pre => { + const btn = document.createElement('button'); + btn.className = 'copy'; + btn.type = 'button'; + btn.textContent = 'Copy'; + btn.addEventListener('click', async () => { + const text = pre.querySelector('code')?.textContent ?? pre.textContent; + try { + await navigator.clipboard.writeText(text.replace(/\n$/, '')); + btn.textContent = 'Copied'; + btn.classList.add('copied'); + setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1400); + } catch { + btn.textContent = 'Press ⌘C'; + } + }); + pre.appendChild(btn); + }); + + // Anchor links on headings + document.querySelectorAll('.doc :is(h2,h3,h4)').forEach(h => { + if (!h.id) return; + const a = document.createElement('a'); + a.className = 'anchor'; + a.href = `#${h.id}`; + a.setAttribute('aria-label', `Anchor link to ${h.textContent}`); + a.textContent = '#'; + h.prepend(a); + }); + + // TOC active highlight via IntersectionObserver + const tocLinks = Array.from(document.querySelectorAll('.toc a')); + if (tocLinks.length) { + const tocMap = new Map(tocLinks.map(a => [a.getAttribute('href').slice(1), a])); + const headings = Array.from(document.querySelectorAll('.doc :is(h2,h3,h4)')).filter(h => tocMap.has(h.id)); + const visible = new Set(); + const setActive = () => { + tocLinks.forEach(a => a.classList.remove('active')); + // Pick the topmost visible heading + const ordered = headings.filter(h => visible.has(h.id)); + const winner = ordered[0] || headings.find(h => visible.has(h.id)); + if (winner) tocMap.get(winner.id)?.classList.add('active'); + }; + const obs = new IntersectionObserver((entries) => { + entries.forEach(e => { + if (e.isIntersecting) visible.add(e.target.id); + else visible.delete(e.target.id); + }); + setActive(); + }, { rootMargin: '-15% 0% -75% 0%', threshold: 0 }); + headings.forEach(h => obs.observe(h)); + } +})(); diff --git a/docs/site/_assets/style.css b/docs/site/_assets/style.css new file mode 100644 index 0000000..3d5a2e8 --- /dev/null +++ b/docs/site/_assets/style.css @@ -0,0 +1,238 @@ +:root{ + --bg0:#07080a; + --bg1:#0a0b0f; + --bg2:#0e1020; + --panel:rgba(255,255,255,0.045); + --panel2:rgba(255,255,255,0.075); + --line:rgba(255,255,255,0.10); + --line-soft:rgba(255,255,255,0.05); + --ink:rgba(255,255,255,0.96); + --text:rgba(255,255,255,0.86); + --muted:rgba(255,255,255,0.62); + --subtle:rgba(255,255,255,0.42); + --tide:#0a84ff; + --tide2:#1d8eff; + --moon:#bf5af2; + --hot:#ff375f; + --accent:var(--tide); + --accent-soft:rgba(10,132,255,0.16); + --accent-strong:#3aa0ff; + --paper:rgba(255,255,255,0.03); + --code-bg:#06080d; + --code-fg:#e6edf3; + --code-border:rgba(255,255,255,0.08); + --shadow-card:0 18px 40px rgba(0,0,0,0.55); + --hl-keyword:#82aaff; + --hl-string:#a6e3a1; + --hl-number:#f0a868; + --hl-comment:#6b7388; + --hl-flag:#c4a4ff; + --hl-meta:#ff8aa0; + --hl-prompt:#7e8ba3; + --glow-primary:rgba(10,132,255,0.13); + --glow-secondary:rgba(191,90,242,0.09); + --chrome-bg:rgba(7,8,10,0.85); + --chrome-bg-solid:rgba(7,8,10,0.96); + --toggle-bg:rgba(7,8,10,0.8); + --selection-fg:#ffffff; + --mark-core:#04101e; + --mark-glint:rgba(255,255,255,0.2); + --pre-scrollbar:#334155; + --copy-bg:rgba(255,255,255,.06); + --copy-border:rgba(255,255,255,.16); + --mobile-shadow:0 18px 40px rgba(0,0,0,.45); + --serif:"Fraunces",ui-serif,Georgia,serif; + --sans:"Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; + --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace; + color-scheme:dark; +} +html[data-theme="light"]{ + --bg0:#f7f9fc; + --bg1:#eef2f8; + --bg2:#e6ecf5; + --panel:rgba(8,18,30,0.055); + --panel2:rgba(8,18,30,0.085); + --line:rgba(8,18,30,0.14); + --line-soft:rgba(8,18,30,0.07); + --ink:#0a1118; + --text:rgba(8,18,30,0.84); + --muted:rgba(8,18,30,0.60); + --subtle:rgba(8,18,30,0.42); + --tide:#0a66c2; + --tide2:#0a76dc; + --moon:#8e3ec5; + --hot:#c8264a; + --accent:var(--tide); + --accent-soft:rgba(10,102,194,0.13); + --accent-strong:#0a52a0; + --paper:rgba(8,18,30,0.035); + --code-bg:#0e151f; + --code-fg:#eef4fa; + --code-border:rgba(8,18,30,0.12); + --shadow-card:0 18px 40px rgba(8,18,30,0.14); + --hl-keyword:#0a66c2; + --hl-string:#216e3c; + --hl-number:#9d5500; + --hl-comment:#7a8497; + --hl-flag:#7e3fb8; + --hl-meta:#c8264a; + --hl-prompt:#8a96aa; + --glow-primary:rgba(10,102,194,0.13); + --glow-secondary:rgba(142,62,197,0.09); + --chrome-bg:rgba(247,249,252,0.88); + --chrome-bg-solid:rgba(247,249,252,0.97); + --toggle-bg:rgba(247,249,252,0.82); + --selection-fg:#ffffff; + --mark-core:#eaf2fc; + --mark-glint:rgba(8,18,30,0.24); + --pre-scrollbar:#94a3b8; + --copy-bg:rgba(255,255,255,.08); + --copy-border:rgba(255,255,255,.18); + --mobile-shadow:0 18px 40px rgba(8,18,30,.18); + color-scheme:light; +} +*{box-sizing:border-box} +html{scroll-behavior:smooth;scroll-padding-top:24px} +body{ + margin:0; + background:radial-gradient(1200px 600px at 70% -10%,var(--glow-primary),transparent 60%), + radial-gradient(900px 500px at 0% 110%,var(--glow-secondary),transparent 55%), + var(--bg0); + color:var(--text); + font-family:var(--sans); + line-height:1.65; + overflow-x:hidden; + -webkit-font-smoothing:antialiased; + font-feature-settings:"ss01","cv11"; +} +::selection{background:var(--tide);color:var(--selection-fg)} +a{color:var(--tide);text-decoration:none;transition:color .12s,opacity .12s} +a:hover{opacity:.85;text-decoration:underline;text-underline-offset:.2em} + +.shell{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh} +.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--chrome-bg);backdrop-filter:saturate(140%) blur(8px);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent} +.sidebar::-webkit-scrollbar{width:6px} +.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px} + +.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px} +.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0} +.brand:hover{text-decoration:none;opacity:1} +.brand .mark{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:10px;background:radial-gradient(circle at 35% 25%,#1d8eff,#0a66c2 60%,#04101e 100%);box-shadow:0 0 0 1px rgba(10,132,255,0.4),0 8px 18px rgba(10,132,255,0.25);position:relative} +.brand .mark::after{content:"";display:block;width:9px;height:14px;border-radius:5px 5px 6px 6px;background:var(--mark-core);box-shadow:0 0 0 1.5px var(--mark-glint)} +.brand strong{display:block;font-family:var(--serif);font-size:1.15rem;line-height:1;font-weight:600;letter-spacing:0.01em;color:var(--ink)} +.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:4px;font-weight:400} + +.theme-toggle{display:inline-flex;align-items:center;gap:7px;height:32px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font:600 .74rem/1 var(--sans);cursor:pointer;padding:0 9px;transition:border-color .15s,color .15s,background .15s} +.theme-toggle:hover{border-color:var(--tide);color:var(--ink);background:var(--panel2)} +.theme-toggle:focus-visible{outline:0;box-shadow:0 0 0 3px var(--accent-soft);border-color:var(--tide)} +.theme-toggle__icon{width:14px;height:14px;border-radius:50%;border:2px solid var(--moon);box-shadow:inset -4px -3px 0 var(--moon);transition:background .15s,box-shadow .15s,border-color .15s} +html[data-theme="light"] .theme-toggle__icon{background:var(--moon);box-shadow:0 0 0 3px rgba(142,62,197,.14);border-color:var(--moon)} + +.search{display:block;margin:0 0 22px} +.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:7px} +.search input{width:100%;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s} +.search input::placeholder{color:var(--subtle)} +.search input:focus{border-color:var(--tide);box-shadow:0 0 0 3px var(--accent-soft)} + +nav section{margin:0 0 18px} +nav h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.10em;margin:0 0 6px;font-weight:600} +.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s} +.nav-link:hover{background:var(--panel);color:var(--ink);text-decoration:none;opacity:1} +.nav-link.active{background:var(--accent-soft);color:var(--tide);font-weight:600} + +main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%} + +.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap} +.hero-text{min-width:0;flex:1 1 320px} +.eyebrow{margin:0 0 8px;color:var(--tide);font-weight:600;text-transform:uppercase;letter-spacing:0.10em;font-size:.7rem} +.hero h1{font-family:var(--serif);font-size:2.4rem;line-height:1.05;letter-spacing:0;margin:0;font-weight:600;color:var(--ink)} +.hero p.lede{margin:.7em 0 0;color:var(--muted);font-size:1.05rem;max-width:60ch} +.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap} +.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:8px;padding:7px 12px;font-weight:500;font-size:.83rem;background:var(--panel);transition:border-color .15s,color .15s,background .15s} +.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--tide);color:var(--ink);text-decoration:none;opacity:1} +.edit{color:var(--muted)} + +.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px} +@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 220px;justify-content:start}} +.doc{min-width:0;max-width:72ch;overflow-wrap:break-word} +.doc h1{font-family:var(--serif);font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:600;color:var(--ink)} +body:not(.home) .doc>h1:first-child{display:none} +.doc h2{font-family:var(--serif);font-size:1.55rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative} +.doc h3{font-family:var(--serif);font-size:1.2rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0} +.doc h4{font-size:1rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600} +.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em} +.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s} +.doc :is(h2,h3,h4):hover .anchor{opacity:.7} +.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--tide);text-decoration:none} +.doc p{margin:0 0 1.05em} +.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em} +.doc li{margin:.25em 0} +.doc li>p{margin:0 0 .4em} +.doc strong{font-weight:600;color:var(--ink)} +.doc em{font-style:italic;color:var(--ink)} +.doc code{font-family:var(--mono);font-size:.84em;background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--ink)} +.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:var(--pre-scrollbar) transparent;border:1px solid var(--code-border)} +.doc pre::-webkit-scrollbar{height:8px;width:8px} +.doc pre::-webkit-scrollbar-thumb{background:var(--pre-scrollbar);border-radius:8px} +.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre} +.doc pre .copy{position:absolute;top:8px;right:8px;background:var(--copy-bg);color:var(--code-fg);border:1px solid var(--copy-border);border-radius:6px;padding:3px 9px;font:500 .7rem/1 var(--sans);cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s} +.doc pre:hover .copy,.doc pre .copy:focus{opacity:1} +.doc pre .copy:hover{background:rgba(255,255,255,.12)} +.doc pre .copy.copied{background:var(--tide);border-color:var(--tide);color:var(--selection-fg);opacity:1} +.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--tide);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)} +.doc blockquote p:last-child{margin-bottom:0} +.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em} +.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top} +.doc th{font-weight:600;color:var(--ink);background:var(--panel);border-bottom:1px solid var(--line)} +.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0} + +.kbd{font:600 .78em/1.4 var(--mono);border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;padding:.05em .45em;background:var(--panel);color:var(--ink);white-space:nowrap} + +.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:1.4em 0} +.card{display:block;border:1px solid var(--line);background:var(--panel);border-radius:12px;padding:16px 18px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,background .18s} +.card:hover{border-color:var(--tide);text-decoration:none;color:var(--ink);transform:translateY(-1px)} +.card h3{margin:0 0 6px;color:var(--ink);font-family:var(--serif);font-size:1.05rem;font-weight:600} +.card p{margin:0;color:var(--muted);font-size:.88rem;line-height:1.45} + +.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent} +.toc::-webkit-scrollbar{width:5px} +.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px} +.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.10em;margin:0 0 10px;font-weight:600} +.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s} +.toc a:hover{color:var(--ink);text-decoration:none;opacity:1} +.toc a.active{color:var(--tide);border-left-color:var(--tide);font-weight:500} +.toc-l3{padding-left:22px!important;font-size:.94em} +@media(max-width:1179px){.toc{display:none}} + +.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px} +.page-nav>a{display:block;border:1px solid var(--line);background:var(--panel);border-radius:10px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,background .18s} +.page-nav>a:hover{border-color:var(--tide);text-decoration:none;color:var(--ink);opacity:1} +.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0.10em;margin-bottom:5px;font-weight:600} +.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)} +.page-nav-prev{text-align:left} +.page-nav-next{text-align:right;grid-column:2} +.page-nav-prev:only-child{grid-column:1} + +.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:42px;height:42px;border-radius:10px;background:var(--toggle-bg);backdrop-filter:blur(10px);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:11px 10px;flex-direction:column;align-items:stretch;justify-content:space-between} +.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s} +.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)} +.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0} +.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)} +@media(max-width:900px){ + .shell{display:block} + .sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease;box-shadow:var(--mobile-shadow);background:var(--chrome-bg-solid);pointer-events:none} + .sidebar.open{transform:translateX(0);pointer-events:auto} + .nav-toggle{display:flex} + main{padding:64px 18px 56px} + .hero{padding-top:6px} + .hero h1{font-size:1.9rem} + .doc h1{font-size:2.1rem} + .hero-meta{width:100%;justify-content:flex-start} + .doc{padding:0} + .doc-grid{margin-top:18px;gap:24px} + .doc :is(h2,h3,h4) .anchor{display:none} +} +@media(max-width:520px){ + main{padding:60px 14px 48px} + .doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0} +} diff --git a/docs/site/_template.html b/docs/site/_template.html new file mode 100644 index 0000000..0757713 --- /dev/null +++ b/docs/site/_template.html @@ -0,0 +1,78 @@ + + + + + + {{TITLE}} + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+

{{SECTION}}

+

{{HERO_TITLE}}

+ {{LEDE}} +
+ +
+ +
+
+{{CONTENT}} +{{PAGE_NAV}} +
+ {{TOC}} +
+
+
+ + + diff --git a/docs/site/build.py b/docs/site/build.py new file mode 100644 index 0000000..ec241fd --- /dev/null +++ b/docs/site/build.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Optune docs site generator. + +Reads `pages/*.md` (with YAML-ish frontmatter), templates them through +`_template.html`, and writes static HTML to `dist/`. Inspired by peekaboo.sh. + +Frontmatter keys: + title: page

+ (required) + section: sidebar group name (required, uppercased automatically) + order: sort key inside the section (number, lower first) + description: meta description (optional) + lede: hero subtitle paragraph (optional) + hero_title: override for the big <h1> (optional, defaults to title) + hide_toc: if true, suppress the right-rail TOC +""" +from __future__ import annotations + +import html +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).parent +PAGES_DIR = ROOT / "pages" +ASSETS_DIR = ROOT / "_assets" +DIST = ROOT / "dist" +TEMPLATE = (ROOT / "_template.html").read_text() + + +# ---------- Frontmatter + Markdown helpers ---------- + +def parse_frontmatter(text: str) -> tuple[dict, str]: + if not text.startswith("---\n"): + return {}, text + end = text.find("\n---", 4) + if end == -1: + return {}, text + block = text[4:end] + body = text[end + 4 :].lstrip("\n") + meta: dict = {} + for line in block.splitlines(): + line = line.rstrip() + if not line or line.startswith("#"): + continue + m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line) + if not m: + continue + key, val = m.group(1), m.group(2).strip() + if val.lower() in ("true", "false"): + meta[key] = val.lower() == "true" + elif re.fullmatch(r"-?\d+", val): + meta[key] = int(val) + else: + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + meta[key] = val + return meta, body + + +def slugify(text: str) -> str: + s = re.sub(r"[^\w\s-]", "", text.lower()).strip() + s = re.sub(r"[\s_-]+", "-", s) + return s.strip("-") + + +# ---------- Mini Markdown renderer ---------- +# Handles: headings (#…####), paragraphs, lists (ul/ol, nested 1 level), +# fenced code (```lang), inline code, bold/italic, links, blockquotes, +# tables (pipe), horizontal rule, html passthrough for <div>/<details>. + +INLINE_CODE = re.compile(r"`([^`]+?)`") +BOLD = re.compile(r"\*\*([^*]+?)\*\*") +ITALIC = re.compile(r"(?<!\*)\*([^*\n]+?)\*(?!\*)") +LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +KBD = re.compile(r"\|kbd\|([^|]+)\|") + + +def render_inline(text: str) -> str: + # Protect code spans first + placeholders: list[str] = [] + + def protect_code(m: re.Match) -> str: + placeholders.append(html.escape(m.group(1))) + return f"\x00CODE{len(placeholders)-1}\x00" + + text = INLINE_CODE.sub(protect_code, text) + text = html.escape(text, quote=False) + text = BOLD.sub(r"<strong>\1</strong>", text) + text = ITALIC.sub(r"<em>\1</em>", text) + text = LINK.sub(r'<a href="\2">\1</a>', text) + text = KBD.sub(r'<span class="kbd">\1</span>', text) + # Restore code + text = re.sub( + r"\x00CODE(\d+)\x00", + lambda m: f"<code>{placeholders[int(m.group(1))]}</code>", + text, + ) + return text + + +@dataclass +class Heading: + level: int + text: str + id: str + + +def render_markdown(md: str) -> tuple[str, list[Heading]]: + out: list[str] = [] + headings: list[Heading] = [] + lines = md.split("\n") + i = 0 + in_list = False + list_kind = "ul" + + def close_list(): + nonlocal in_list + if in_list: + out.append(f"</{list_kind}>") + in_list = False + + while i < len(lines): + line = lines[i] + + # Fenced code block + m = re.match(r"^```(\w*)\s*$", line) + if m: + close_list() + lang = m.group(1) + i += 1 + buf: list[str] = [] + while i < len(lines) and not lines[i].startswith("```"): + buf.append(lines[i]) + i += 1 + i += 1 # closing fence + code = "\n".join(buf) + attr = f' class="language-{lang}"' if lang else "" + out.append(f'<pre><code{attr}>{html.escape(code)}</code></pre>') + continue + + # Horizontal rule + if re.match(r"^---+\s*$", line): + close_list() + out.append("<hr>") + i += 1 + continue + + # Heading + m = re.match(r"^(#{1,4})\s+(.*?)\s*$", line) + if m: + close_list() + level = len(m.group(1)) + txt = m.group(2) + slug = slugify(txt) + headings.append(Heading(level, txt, slug)) + out.append( + f'<h{level} id="{slug}">{render_inline(txt)}</h{level}>' + ) + i += 1 + continue + + # Blockquote (single line, can repeat) + if line.startswith("> "): + close_list() + buf = [] + while i < len(lines) and lines[i].startswith(">"): + buf.append(lines[i].lstrip("> ").rstrip()) + i += 1 + out.append( + "<blockquote><p>" + render_inline(" ".join(buf)) + "</p></blockquote>" + ) + continue + + # Table — header / divider / rows + if "|" in line and i + 1 < len(lines) and re.match(r"^\s*\|?[\s|:-]+\|?\s*$", lines[i + 1]): + close_list() + def split_row(s: str) -> list[str]: + s = s.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + return [c.strip() for c in s.split("|")] + headers = split_row(line) + i += 2 # skip divider + rows = [] + while i < len(lines) and "|" in lines[i] and lines[i].strip(): + rows.append(split_row(lines[i])) + i += 1 + out.append("<table>") + out.append("<thead><tr>" + "".join(f"<th>{render_inline(h)}</th>" for h in headers) + "</tr></thead>") + out.append("<tbody>") + for r in rows: + out.append("<tr>" + "".join(f"<td>{render_inline(c)}</td>" for c in r) + "</tr>") + out.append("</tbody></table>") + continue + + # Unordered list + m = re.match(r"^([-*])\s+(.*)$", line) + if m: + if not in_list or list_kind != "ul": + close_list() + out.append("<ul>") + in_list = True + list_kind = "ul" + out.append(f"<li>{render_inline(m.group(2))}</li>") + i += 1 + continue + + # Ordered list + m = re.match(r"^(\d+)\.\s+(.*)$", line) + if m: + if not in_list or list_kind != "ol": + close_list() + out.append("<ol>") + in_list = True + list_kind = "ol" + out.append(f"<li>{render_inline(m.group(2))}</li>") + i += 1 + continue + + # Raw HTML passthrough (single line) + if line.startswith("<") and not line.startswith("<!"): + close_list() + out.append(line) + i += 1 + continue + + # Blank line + if not line.strip(): + close_list() + i += 1 + continue + + # Paragraph (consume contiguous non-empty lines) + close_list() + buf = [line] + i += 1 + while i < len(lines) and lines[i].strip() and not re.match(r"^(#{1,4}\s|>|```|-{3,}|\d+\.\s|[-*]\s)", lines[i]): + buf.append(lines[i]) + i += 1 + out.append("<p>" + render_inline(" ".join(buf)) + "</p>") + + close_list() + return "\n".join(out), headings + + +# ---------- Site assembly ---------- + +def load_pages() -> list[dict]: + pages = [] + for md_path in sorted(PAGES_DIR.glob("*.md")): + text = md_path.read_text() + meta, body = parse_frontmatter(text) + meta.setdefault("section", "DOCS") + meta.setdefault("order", 100) + meta.setdefault("title", md_path.stem.replace("-", " ").title()) + meta["slug"] = md_path.stem + meta["body"] = body + pages.append(meta) + return pages + + +def order_pages(pages: list[dict]) -> list[dict]: + return sorted(pages, key=lambda p: (p.get("nav_order", p["order"]), p["order"], p["slug"])) + + +def section_order(pages: list[dict]) -> list[str]: + """Preserve first-seen section order from the ordered pages list.""" + seen: list[str] = [] + for p in pages: + sec = p["section"].upper() + if sec not in seen: + seen.append(sec) + return seen + + +def render_sidebar(pages: list[dict], current_slug: str) -> str: + sections: dict[str, list[dict]] = {} + for p in pages: + sections.setdefault(p["section"].upper(), []).append(p) + chunks = [] + for sec in section_order(pages): + chunks.append(f"<section><h2>{html.escape(sec)}</h2>") + for p in sections[sec]: + cls = "nav-link active" if p["slug"] == current_slug else "nav-link" + chunks.append( + f'<a class="{cls}" href="{p["slug"]}.html">{html.escape(p["title"])}</a>' + ) + chunks.append("</section>") + return "\n".join(chunks) + + +def render_toc(headings: list[Heading]) -> str: + h2_h3 = [h for h in headings if h.level in (2, 3)] + if len(h2_h3) < 2: + return "" + lines = ['<aside class="toc"><h2>On this page</h2>'] + for h in h2_h3: + cls = "toc-l3" if h.level == 3 else "" + lines.append(f'<a href="#{h.id}" class="{cls}">{html.escape(h.text)}</a>') + lines.append("</aside>") + return "\n".join(lines) + + +def render_page_nav(pages: list[dict], idx: int) -> str: + prev = pages[idx - 1] if idx > 0 else None + nxt = pages[idx + 1] if idx + 1 < len(pages) else None + if not prev and not nxt: + return "" + parts = ['<nav class="page-nav">'] + if prev: + parts.append( + f'<a class="page-nav-prev" href="{prev["slug"]}.html"><small>← Previous</small><span>{html.escape(prev["title"])}</span></a>' + ) + if nxt: + parts.append( + f'<a class="page-nav-next" href="{nxt["slug"]}.html"><small>Next →</small><span>{html.escape(nxt["title"])}</span></a>' + ) + parts.append("</nav>") + return "\n".join(parts) + + +def build(): + if DIST.exists(): + shutil.rmtree(DIST) + DIST.mkdir(parents=True) + # Copy assets + assets_out = DIST / "_assets" + shutil.copytree(ASSETS_DIR, assets_out) + # Favicon at root + shutil.copy(ASSETS_DIR / "favicon.svg", DIST / "favicon.svg") + + raw_pages = load_pages() + pages = order_pages(raw_pages) + if not pages: + print("No pages found in pages/", file=sys.stderr) + sys.exit(1) + + for idx, page in enumerate(pages): + body_html, headings = render_markdown(page["body"]) + sidebar = render_sidebar(pages, page["slug"]) + toc = "" if page.get("hide_toc") else render_toc(headings) + page_nav = render_page_nav(pages, idx) + lede = f'<p class="lede">{render_inline(page["lede"])}</p>' if page.get("lede") else "" + body_class = "home" if page["slug"] == "index" else "" + + out = (TEMPLATE + .replace("{{TITLE}}", html.escape(f"{page['title']} — Optune")) + .replace("{{DESCRIPTION}}", html.escape(page.get("description", "Optune — open-source Logitech control for macOS."))) + .replace("{{PATH}}", "" if page["slug"] == "index" else f"{page['slug']}.html") + .replace("{{ASSETS}}", "_assets") + .replace("{{ROOT}}", "") + .replace("{{BODY_CLASS}}", body_class) + .replace("{{SIDEBAR}}", sidebar) + .replace("{{SECTION}}", html.escape(page["section"].upper())) + .replace("{{HERO_TITLE}}", html.escape(page.get("hero_title", page["title"]))) + .replace("{{LEDE}}", lede) + .replace("{{SLUG}}", page["slug"]) + .replace("{{CONTENT}}", body_html) + .replace("{{PAGE_NAV}}", page_nav) + .replace("{{TOC}}", toc) + ) + out_path = DIST / f"{page['slug']}.html" + out_path.write_text(out) + print(f" {out_path.relative_to(ROOT)}") + + print(f"\n✓ Built {len(pages)} page(s) to {DIST.relative_to(ROOT)}/") + + +if __name__ == "__main__": + build() diff --git a/docs/site/pages/acknowledgements.md b/docs/site/pages/acknowledgements.md new file mode 100644 index 0000000..3d5328a --- /dev/null +++ b/docs/site/pages/acknowledgements.md @@ -0,0 +1,35 @@ +--- +title: Acknowledgements +section: About +order: 310 +description: People and projects that made Optune possible. +lede: Optune doesn't share code with any HID++ library, but it stands on a decade of reverse-engineering work by the Linux community. Credit where it's due. +--- + +# Acknowledgements + +## HID++ reverse engineering + +Optune's HID++ feature decoders are clean-room Swift, but the **specifications** they decode are the result of years of careful reverse-engineering work by: + +- **[Solaar](https://github.com/pwr-Solaar/Solaar)** — the canonical Linux Logitech control suite. Their `lib/logitech_receiver/hidpp20.py` is the reference for what a feature ID does and how its capabilities map. +- **[logiops](https://github.com/PixlOne/logiops)** — a C++ daemon for Linux that taught me how `ReprogControlsV4` divert really works. +- **[hid-tools](https://gitlab.freedesktop.org/libevdev/hid-tools)** — the Python toolkit for capturing and replaying HID frames; invaluable for figuring out what a firmware actually sends. + +If you use Linux, install Solaar. It's wonderful. Optune exists because macOS deserves something equivalent that doesn't run as a kernel extension. + +## Mac-side inspiration + +- **[Mouser](https://github.com/TomBadash/Mouser)** — the named action catalog idea (40+ presets across 8 categories) is directly inspired by Mouser's action picker. Optune's catalog is a fresh Swift implementation but the UX pattern is theirs. +- **[Karabiner-Elements](https://github.com/pqrs-org/Karabiner-Elements)** — the gold standard for "how to do `CGEventTap` correctly on macOS". Optune's `RemapEngine` borrows the divert-then-synthesize pattern. +- **[Logitune](https://github.com/mmaher88/logitune)** — an earlier MX Master menu-bar utility that proved the IOKit HIDManager path works on macOS without elevated privileges. + +## Tooling + +- **[Sparkle](https://sparkle-project.org/)** isn't a dependency — Optune polls GitHub Releases directly to avoid the appcast plumbing. But the Sparkle docs are still the right reading material if you want to understand auto-update UX. +- **[create-dmg](https://github.com/create-dmg/create-dmg)** — produces the universal DMG in CI. +- **[Peekaboo](https://peekaboo.sh/)** — this docs site copies Peekaboo's design language. The structure (sidebar + content + TOC), the typography (Fraunces + JetBrains Mono), and the dark/light theme machinery are all theirs. We swapped the green ecto accent for macOS system blue. + +## Thanks + +Anyone who's filed an issue or sent a `devices.json` PR — that's how this project gets better. If your device works in Optune today, someone before you likely captured a feature dump and pushed it. diff --git a/docs/site/pages/adding-a-device.md b/docs/site/pages/adding-a-device.md new file mode 100644 index 0000000..06608c2 --- /dev/null +++ b/docs/site/pages/adding-a-device.md @@ -0,0 +1,85 @@ +--- +title: Adding a device +section: Reference +order: 220 +description: Add a new Logitech device to Optune in one PR — no Swift changes, just a JSON entry. +lede: Got a Logitech device that isn't in the registry? Add it in one PR — pure JSON, no Swift, runs in CI. +--- + +# Adding a device + +## Why a registry + +Optune's `DeviceRegistry` is a JSON file at `Sources/OptuneCore/Resources/devices.json`. It maps a Logitech wireless PID (or USB VID:PID pair) to: + +- Friendly name + family +- Sensor DPI range (firmware ground truth) +- Capability flags — does this device have SmartShift? Backlight? Onboard slots? + +The registry exists because firmware reports can lie or be missing. A device that exposes `AdjustableDPI` might have a sensible `[200, 8000]` range over USB but report `[0, 0]` over BLE on first connection. The registry gives us a fallback so the UI doesn't show 0 DPI for half a second on every wake. + +It also lets us **gate UI per family** — MX Anywhere has no SmartShift, so the Wheel pane hides SmartShift for that family. + +## The JSON shape + +Open `Sources/OptuneCore/Resources/devices.json` and you'll see entries like: + +```json +{ + "wirelessPID": "B034", + "name": "MX Master 3S", + "family": "mx_master", + "dpiRange": { "min": 200, "max": 8000, "step": 50 }, + "capabilities": { + "smartShift": true, + "onboardProfiles": true, + "buttonRemap": true, + "hosts": 3, + "backlight": false, + "fnLock": false + } +} +``` + +## Steps to add yours + +1. **Find the wireless PID.** Run `optune doctor`. Look for a row like `productID=0xB034 transport=BLE`. The four hex digits without `0x` are the wireless PID. +2. **Look up the sensor range.** Logitech publishes the DPI range on the product page. Or, if you trust your firmware, run `optune dpi` and copy the range from `--json` output. +3. **Determine capabilities.** Run `optune doctor --features` to see which HID++ features the firmware advertises. Cross-reference: + + | Feature ID | Capability flag | + |---|---| + | `0x2111` | `smartShift` | + | `0x8100` | `onboardProfiles` | + | `0x1B04` | `buttonRemap` | + | `0x1815` | `hosts` (set to `getHostCount` result) | + | `0x1982` | `backlight` | + | `0x40A3` | `fnLock` | + +4. **Add the entry** to `devices.json` in alphabetical order by `wirelessPID`. +5. **Add a test fixture** at `Tests/OptuneCoreTests/Fixtures/<pid>.bin` with a captured HID++ feature-set dump. (Optional but encouraged — it locks the registry against firmware changes.) +6. **Run the tests.** + + ```bash + swift test --filter DeviceRegistryTests + ``` + + The `DeviceRegistryTests` suite parses the JSON, checks every entry has a unique PID, validates the DPI range monotonicity, and confirms each capability flag corresponds to a known feature ID. + +7. **Open a PR.** No Swift changes, no Xcode project edits — just a JSON delta and (ideally) a fixture. CI runs the suite on macos-15 with full Xcode and signs off. + +## Gotchas + +- **USB and BLE pairings have different PIDs.** Add both. The MX Master 3S is `B034` over BLE and `406A` over a Bolt receiver. +- **Don't make up a `family`.** If yours doesn't fit the existing list (`mx_master`, `mx_anywhere`, `mx_vertical`, `mx_keys`, `mx_keys_mini`), pick the closest one. The family controls some UI gating; orphan families silently lose features. +- **`step` matters.** A device with a 50-DPI step rejects `setSensorDpi(1601)` with `INVALID_ARGUMENT`. The slider snaps to the step. + +## Live testing without a release + +Once the PR merges, the device works in `swift run optune` immediately. To verify the menu-bar app picks it up: + +```bash +swift build -c release +bash Scripts/bundle-app.sh release +open .build/OptuneApp.app +``` diff --git a/docs/site/pages/architecture.md b/docs/site/pages/architecture.md new file mode 100644 index 0000000..acd04a0 --- /dev/null +++ b/docs/site/pages/architecture.md @@ -0,0 +1,99 @@ +--- +title: Architecture +section: Reference +order: 210 +description: How OptuneCore, OptuneApp, and the CLI fit together. A guided tour of the HID++ stack and the SwiftUI surface. +lede: A guided tour of the layers — IOKit at the bottom, SwiftUI at the top, OptuneCore in the middle keeping both honest. +--- + +# Architecture + +## Layers + +``` +┌─────────────────────────────────────────┐ +│ SwiftUI (menu bar + Settings + welcome)│ OptuneApp +│ RemapEngine, ProfileStore, Notifications│ +├─────────────────────────────────────────┤ +│ DeviceModel, DeviceRegistry │ OptuneCore +│ HIDPP feature set (battery, dpi, ...) │ (Swift Package) +│ HIDPPTransport │ +├─────────────────────────────────────────┤ +│ IOHIDManager + IOHIDDevice │ IOKit +└─────────────────────────────────────────┘ +``` + +Two products, one package: + +- **`OptuneCore`** — pure Swift package. No UIKit, no SwiftUI, no AppKit. Imports IOKit and Foundation only. Used by both the app and the CLI. +- **`OptuneApp`** — the SwiftUI app target. Owns Settings, the menu bar, profiles, notifications, the remap engine. + +The CLI is a separate executable target inside the same Swift package. It depends on OptuneCore and prints — no GUI imports, so it builds on a headless macOS runner. + +## Device discovery + +`HIDPPTransport` opens an `IOHIDManager` with a matching dictionary that picks up Logitech vendor IDs (`0x046D`) and the HID++ usage page. When IOKit fires the `Matching` callback: + +1. We call `IOHIDDeviceOpen` with `kIOHIDOptionsTypeSeizeDevice = 0` (we share, we don't seize) +2. We register an input report callback for **report 0x11** (HID++ long, 20 bytes) +3. We send a `getProtocolVersion` request — if the device responds, it's HID++ 2.0 +4. We send `getFeatureSet.getCount` to enumerate features, then `getFeatureID(i)` for each feature index + +The result is a `Device` value with a `featureMap: [FeatureID: FeatureIndex]`. Every subsequent feature call uses the cached index. + +## HID++ frame layout + +A HID++ "long" report (`0x11`) is exactly 20 bytes: + +``` +[ report=0x11 | dev=0xFF | featIdx | funcIdx<<4 | swid | param0..param15 ] +``` + +- `dev` is `0xFF` for the receiver itself, otherwise the slot of the connected device on the receiver. +- `featIdx` is the feature index from the cached feature map. +- `funcIdx` is the function index inside that feature, shifted left 4 bits. +- `swid` (software ID) is a 4-bit cookie we set per request to match responses to requests. +- `param0..15` is the function-specific payload. + +`HIDPPTransport.send(...)` handles the cookie, async/await pairing, and timeouts. Features built on top — `UnifiedBattery`, `AdjustableDPI`, etc. — are typed wrappers that encode the params and decode the response. + +## DeviceModel and ProfileStore + +`DeviceModel` is the per-device `@MainActor` ObservableObject the SwiftUI views observe. It owns: + +- The current battery snapshot +- The current DPI value + range +- The current SmartShift state +- A reference to the active `Profile` (global or per-app) + +`ProfileStore` holds the profile dictionary keyed by bundle ID and persists to disk. The `NSWorkspace` activation observer lives in `OptuneApp.swift` and calls `DeviceModel.apply(profile)` directly. + +## RemapEngine + +`RemapEngine` is the trickiest bit. When you bind a button to an action: + +1. The engine asks `ReprogControlsV4.setRemap(button, divert: true)`. This tells the firmware "stop firing the original event, send a divert HID++ packet instead". +2. The engine subscribes to divert packets via the HID++ transport's notification callback. +3. When a divert arrives, the engine looks up the current binding (per-app first, global fallback) and calls `fire(action:)`. +4. `fire(action:)` posts the synthesized event using whichever primitive matches — `CGEvent` for keystrokes / mouse clicks, `NSEvent.otherEvent + NSApp.postEvent` for media keys. + +The engine **never holds** a strong reference to `DeviceModel`. Cycle DPI, Toggle SmartShift, Toggle Scroll Mode all go through `RemapActionDispatcher.shared`, a tiny singleton bridge that `DeviceModel` registers closures into at init time. + +## Single-instance guard + +Optune is a menu-bar app — running two copies makes no sense. `SingleInstanceGuard.acquireOrTrigger()` runs in `App.init()`: + +1. If we're the **first** copy, register a `DistributedNotificationCenter` observer for `optune.activate` and proceed normally. +2. If we're the **second** copy, post `optune.activate` and call `exit(0)`. + +The first copy's observer brings up Settings via `NSApp.sendAction(showSettingsWindow:, ...)`, so launching from the dock or `open -a Optune` while one is running just focuses the existing instance. + +## Error handling philosophy + +Every HID++ call returns `Result<Response, HIDPPError>`. Errors fall into: + +- **Transport** — IOKit returned a kernel error, the device disappeared, the report didn't arrive in 1000 ms. +- **Protocol** — the device returned a HID++ error code (`INVALID_FUNCTION_ID`, `INVALID_ARGUMENT`, `OUT_OF_RANGE`). +- **Capability** — we asked for a feature the device doesn't have. + +The UI never crashes on an HID++ error. Worst case a pane shows an empty state with a tooltip explaining the firmware response. The CLI exits with a non-zero code and prints the error to stderr. diff --git a/docs/site/pages/battery.md b/docs/site/pages/battery.md new file mode 100644 index 0000000..a135c4c --- /dev/null +++ b/docs/site/pages/battery.md @@ -0,0 +1,80 @@ +--- +title: Battery +section: Features +order: 100 +description: How Optune reads UnifiedBattery (HID++ 0x1004) and what the percent, status, and charge fields mean. +lede: One feature, four meaningful bits — percent, status, charge state, and external power. Optune polls UnifiedBattery (0x1004) on a 30-second cadence and keeps a rolling sparkline. +--- + +# Battery + +## The feature: UnifiedBattery (`0x1004`) + +Modern Logitech mice and keyboards expose battery level via the **UnifiedBattery** feature. It supersedes the legacy `BatteryStatus` (`0x1000`) and `BatteryVoltage` (`0x1001`) features and reports a clean percentage instead of a coarse "low / good / full" enum. + +The feature has two main reads: + +- **GetCapabilities** (`fn 0x00`) — tells you which optional fields the firmware supports. +- **GetStatus** (`fn 0x10`) — returns the live snapshot. + +## Status report layout + +The 16-byte response from `GetStatus` parses as: + +``` +byte 0 percent 0..100, or 0xFF if unknown +byte 1 level mask enum (critical, low, good, full) +byte 2 status 0=discharging 1=charging 2=charge_done 4=charge_slow 8=invalid +byte 3 external power 0=no 1=yes +bytes 4-15 reserved +``` + +Optune surfaces three of these: + +| UI element | Source field | Notes | +|---|---|---| +| Battery pill | `percent` | Shows "?" if firmware returns 0xFF. | +| Charge bolt | `status == charging` | Animated when charging, solid when full, hidden otherwise. | +| External plug glyph | `external power == 1` | Only shown when `status != charging` (charging implies external power). | + +## Why the percent disagrees with Logi Options+ + +UnifiedBattery's percent is calibrated by Logi from a discharge curve baked into firmware — it's the same number Options+ shows. If they disagree, check: + +1. You're looking at the **same device**. MX Master 3S over BLE and over the Bolt receiver appear as two distinct HID interfaces on macOS. +2. Optune polls every 30 seconds. Plugging in shifts the curve immediately; the pill catches up on the next tick. + +## Sparkline history + +The menu-bar dropdown shows a 60-sample rolling sparkline. Each sample is the percent at the time of the poll, persisted to `~/Library/Application Support/Optune/battery-history.json`. + +Three things to know: + +- The sparkline is **per-device** (keyed by serial / wireless PID). +- It does not reset on app launch. +- Samples while charging are stored with a flag, so the line dips and rises distinctly. + +## Notifications + +Optune posts a UNUserNotification once per discharge cycle when: + +- `percent` crosses the **low threshold** (default 20%, configurable in Settings → Notifications) +- `percent` crosses the **critical threshold** (default 5%) +- The device disconnects while below 20% + +The threshold + a "Notify on charge complete" toggle live in Settings. + +## CLI + +```bash +optune battery +optune battery --json +optune battery --device "MX Master 3S" +``` + +`--json` is the format the menu-bar app uses internally; it's stable, you can parse it from a script. + +## Quirks + +- The **MX Anywhere 2S** firmware sometimes reports 0% for a few seconds after wake. Optune debounces — anything that comes within 5 seconds of waking is dropped from the sparkline. +- The **MX Keys S** keyboard rarely returns `external_power=1` while charging; we trust `status==charging` over the `external_power` bit when both are set. diff --git a/docs/site/pages/building.md b/docs/site/pages/building.md new file mode 100644 index 0000000..82ba748 --- /dev/null +++ b/docs/site/pages/building.md @@ -0,0 +1,102 @@ +--- +title: Building from source +section: Reference +order: 230 +description: Clone, build, bundle, and run Optune locally. Requires macOS 15+ and Swift 6.0+. +lede: Optune is a Swift Package — clone, swift build, done. The bundling step that produces Optune.app is one shell script. +--- + +# Building from source + +## Requirements + +- macOS 15 or newer (macOS 26 unlocks Liquid Glass) +- Xcode 16 with the **macOS 15 SDK** — full Xcode, not just Command Line Tools, because XCTest is needed for `swift test` +- Swift 6.0+ + +> If you only have Command Line Tools, `swift build` works fine for the app and CLI, but `swift test` fails with `no such module 'XCTest'`. CI on `macos-15` has the full SDK. + +## Clone & build + +```bash +git clone https://github.com/Sanjays2402/optune.git +cd optune +swift build +swift run optune doctor +``` + +A debug build takes ~3 seconds on an M1. A release build takes ~8 seconds. + +## Run the menu-bar app + +The `swift run` flow only works for the CLI — the menu-bar app needs a real `.app` bundle so AppKit can register itself with the Dock and so the menu-bar status item works. There's a one-shot bundling script: + +```bash +swift build -c release +bash Scripts/bundle-app.sh release +open .build/OptuneApp.app +``` + +`bundle-app.sh` produces: + +``` +.build/OptuneApp.app/ + Contents/ + Info.plist + MacOS/OptuneApp # universal binary + Resources/ + optune # CLI symlink + AppIcon.icns + Assets.car + en.lproj/Localizable.strings +``` + +The bundle is **ad-hoc signed** automatically by Swift. It runs on your machine without a Developer ID. + +## Tests + +```bash +swift test # full suite +swift test --filter DeviceRegistryTests # one suite +``` + +Suites: + +- `OptuneCoreTests` — HID++ frame parser, feature decoders, device-matching logic +- `DeviceRegistryTests` — registry validation (uniqueness, ranges, capability ↔ feature ID) +- `RemapEngineTests` — action catalog mapping, profile fallback logic + +## Continuous integration + +`.github/workflows/ci.yml` runs on `macos-15`. It: + +1. Caches `~/.swiftpm` and `.build` +2. Runs `swift build -c debug -c release` +3. Runs `swift test` +4. Builds the release bundle and uploads it as a workflow artifact + +Branch protection on `main` requires the `build (macos-15)` check to pass with linear history. + +## Releases + +Pushing a tag like `v0.6.1` triggers `.github/workflows/release.yml`: + +1. Runs the same build + test +2. Bundles `Optune.app` +3. Wraps it in a universal DMG via `create-dmg` +4. Generates the SHA-256 +5. Uploads the DMG and the cask manifest to GitHub Releases +6. Triggers `bump-tap.yml` in the homebrew-optune repo with the new version + SHA — the cask updates automatically + +The full pipeline runs in about 5 minutes. + +## Where to look + +| You want to | Look at | +|---|---| +| Add an HID++ feature decoder | `Sources/OptuneCore/HIDPP/Features/` | +| Add a Settings pane | `Sources/OptuneApp/SettingsWindow.swift` | +| Add an action to the catalog | `Sources/OptuneApp/ActionCatalog.swift` | +| Tweak the menu-bar UI | `Sources/OptuneApp/MenuBar/` | +| Fix the bundling script | `Scripts/bundle-app.sh` | +| Update the device registry | `Sources/OptuneCore/Resources/devices.json` | diff --git a/docs/site/pages/buttons.md b/docs/site/pages/buttons.md new file mode 100644 index 0000000..fe8c3d7 --- /dev/null +++ b/docs/site/pages/buttons.md @@ -0,0 +1,70 @@ +--- +title: Buttons & Action Catalog +section: Features +order: 130 +description: Custom button remap with 40+ named actions across 8 categories — keystrokes, mouse, media, system. Powered by ReprogControlsV4 and a CGEventTap. +lede: 40+ named actions across 8 categories. No keycode hunting, no AppleScript, no Karabiner-Elements. Pick from a menu, the binding is live. +--- + +# Buttons + +## What you can bind + +Every remappable control on your device shows up in **Settings → Buttons**. Optune queries `ReprogControlsV4` (`0x1B04`) once per device, gets the native button list (Forward, Back, Wheel Click, Gesture, Top, Smart Shift, etc.), and shows them as rows. + +Each row has a dropdown sourced from the **Action Catalog** — 40+ named actions, organised into 8 categories so you don't have to scroll a flat list of 40 things: + +| Category | Examples | +|---|---| +| **Window** | Mission Control · App Exposé · Show Desktop · Launchpad | +| **Edit** | Cut · Copy · Paste · Undo · Redo · Find · Find Next | +| **Navigation** | Back · Forward · Home · End · Page Up · Page Down | +| **Media** | Play/Pause · Next · Previous · Volume Up/Down · Mute · Brightness Up/Down | +| **Mouse** | Left Click · Right Click · Middle Click · Cycle DPI · Toggle SmartShift · Toggle Scroll Mode | +| **Spaces** | Move Left · Move Right · Switch to Space 1–4 | +| **System** | Spotlight · Notification Center · Lock Screen · Force Quit · Screenshot Region | +| **App Launch** | Open Finder · Open Safari · Open Mail · Custom… | + +The catalog is a Swift `enum RemapAction` with associated values and lives in `Sources/OptuneApp/ActionCatalog.swift`. Reverse lookup is O(1) so the dropdown finds your saved binding instantly. + +## How a remap fires + +When you press a remapped button: + +1. The HID++ device sends a divert event for that button (because Optune set `divert=1` via `ReprogControlsV4.setRemap` at startup). +2. Optune's `RemapEngine` receives the divert in its HID++ listener. +3. It looks up the button in the active profile (per-app or global). +4. It dispatches the action through the right primitive: + +| Action kind | Primitive | +|---|---| +| `keystroke(keyCode, modifiers)` | `CGEvent` keyboard down + up | +| `mouseClick(button)` | `CGEvent` mouse down + up at current cursor location | +| `mediaKey(nx)` | `NSEvent.otherEvent(...) → NSApp.postEvent(...)` (subtype 8 system-defined) | +| `cycleDPI` / `toggleSmartShift` / `toggleScrollMode` | Via `RemapActionDispatcher` singleton, which talks to `DeviceModel` | +| `runShell(path)` | `Process.launch()` (Custom… category) | +| `openApp(bundleID)` | `NSWorkspace.openApplication` | + +The `RemapActionDispatcher` is a thin singleton that exists so the `RemapEngine` doesn't hold a strong reference to `DeviceModel` — that would leak on device disconnect. + +## Per-app overrides + +Each profile in **Settings → Per-App Profiles** has its own bindings dictionary. When you switch focus, the engine swaps the active dictionary in O(1) — there's no rebuild. + +If a per-app profile **doesn't** override a button, the global binding is used. So you can have one global "Mission Control" on the thumb button and only override Cmd+/ in Final Cut. + +## Capabilities & gating + +Some buttons can't be remapped — they're hardware-locked or the firmware refuses divert. The Buttons pane greys those rows out with a tooltip explaining why. Capabilities come from `devices.json` (per-family) and from the live `ReprogControlsV4.getCapabilities` response (per-control). + +> **Why a catalog instead of free-form keystroke entry?** Mouser and Karabiner-Elements teach this lesson hard: 95% of users want "Mission Control", not `keyCode 0x7E + ctrl`. The catalog wraps the keycode primitives behind names, and the **Custom…** entry is still there for the 5%. + +## CLI + +```bash +optune buttons # list controls + current bindings +optune buttons --bind back=mission_control +optune buttons --reset-all +``` + +The CLI uses the same action identifiers the catalog uses — you can find the full list with `optune buttons --catalog`. diff --git a/docs/site/pages/cli.md b/docs/site/pages/cli.md new file mode 100644 index 0000000..c2983c9 --- /dev/null +++ b/docs/site/pages/cli.md @@ -0,0 +1,139 @@ +--- +title: CLI reference +section: Reference +order: 200 +description: The optune CLI is the same code path as the GUI — every feature is scriptable. +lede: Every feature in Optune ships with a CLI command. The GUI and the CLI both call OptuneCore, so anything you can click you can also script. +--- + +# CLI reference + +## Install + +The CLI ships inside the app bundle at `Optune.app/Contents/Resources/optune`. The Homebrew cask symlinks it into `/opt/homebrew/bin/optune` automatically. If you used the DMG, run: + +```bash +sudo ln -sf /Applications/Optune.app/Contents/Resources/optune /usr/local/bin/optune +``` + +## Commands + +### `optune devices` + +List every Logitech device IOKit can see, with HID++ feature support. + +``` +optune devices +optune devices --json +``` + +Output: name, transport (BLE / USB / RF), HID++ version, supported feature IDs. + +### `optune doctor` + +Diagnostic dump. Lists every HID interface, attempts a feature-set query on each, and prints permission state. + +``` +optune doctor +``` + +Run this first when something doesn't work. + +### `optune battery` + +``` +optune battery +optune battery --device "MX Master 3S" +optune battery --json +``` + +### `optune dpi` + +``` +optune dpi # show current + range +optune dpi --set 1600 # set to 1600 DPI +optune dpi --device "MX Master 3S" --set 4000 +``` + +### `optune smartshift` + +``` +optune smartshift # show state +optune smartshift --enable +optune smartshift --disable +optune smartshift --threshold 60 # 1..255 +``` + +### `optune buttons` + +``` +optune buttons # list controls + bindings +optune buttons --catalog # full action catalog +optune buttons --bind back=mission_control +optune buttons --reset-all +``` + +### `optune hosts` + +``` +optune hosts +optune hosts --switch 2 +``` + +### `optune onboard` + +``` +optune onboard # show mode + slot +optune onboard --mode host +optune onboard --mode onboard +optune onboard --slot 1 +``` + +### `optune backlight` + +``` +optune backlight +optune backlight --mode reactive --level 60 +``` + +### `optune fnlock` + +``` +optune fnlock --enable +optune fnlock --disable +``` + +## Global flags + +| Flag | Effect | +|---|---| +| `--device <name>` | Target a specific device when you have multiple. Matches a substring of the model name. | +| `--json` | Emit machine-readable JSON instead of pretty text. Stable across versions — write your scripts against this. | +| `--verbose` | Log every HID++ frame to stderr. Useful when filing a bug. | + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Generic failure (HID++ error, device not found) | +| 2 | Permission denied (Input Monitoring not granted) | +| 3 | Feature not supported on this device | + +## Scripting examples + +**Notify when battery drops below 15%:** + +```bash +#!/bin/bash +pct=$(optune battery --json | jq -r '.[0].percent') +if (( pct < 15 )); then + osascript -e "display notification \"Battery $pct%\" with title \"Mouse\"" +fi +``` + +**Daily report into your timeseries database:** + +```bash +optune battery --json | curl -X POST -d @- https://your-tsdb/ingest +``` diff --git a/docs/site/pages/hidpp.md b/docs/site/pages/hidpp.md new file mode 100644 index 0000000..a72ede8 --- /dev/null +++ b/docs/site/pages/hidpp.md @@ -0,0 +1,77 @@ +--- +title: HID++ primer +section: Reference +order: 250 +description: A short, practical introduction to the HID++ 2.0 protocol — what a feature is, how reports are framed, where the gotchas live. +lede: Everything Optune does sits on top of HID++ 2.0. If you want to add a feature or debug a quirk, this is the 5-minute mental model. +--- + +# HID++ primer + +## What HID++ is + +**HID++** is Logitech's proprietary protocol layered on top of standard USB HID. Every Logitech device speaks two HID interfaces: + +- A normal one — keyboard reports, mouse reports, the stuff macOS reads natively +- A second one carrying HID++ frames + +The second interface uses **report ID 0x10** (short, 7 bytes) and **report ID 0x11** (long, 20 bytes). Optune only ever sends and receives `0x11`. + +Within HID++ there are two protocol versions in the wild: + +- **HID++ 1.0** — used by older devices (MX Anywhere 2 era). Optune doesn't support it. +- **HID++ 2.0** — the current one. Everything in [`devices.json`](adding-a-device.html) is 2.0. + +Detecting the version is one frame: send `getProtocolVersion`, the response tells you. + +## Features, not commands + +HID++ 2.0 organises capabilities as **features**. A feature has: + +- A 16-bit **feature ID** (e.g. `0x1004` for UnifiedBattery, `0x2201` for AdjustableDPI) +- A 4-bit **feature index** assigned by the firmware on a per-device basis +- One or more **functions** (e.g. `getStatus`, `setSensorDpi`) +- An optional set of **events** (firmware-pushed messages) + +The feature index is **not** stable across devices — even two of the same model can assign UnifiedBattery different indices. Always enumerate the feature set on first connection and cache the map. + +## Frame layout + +``` +byte 0 report ID 0x11 (long) +byte 1 device index 0xFF for receiver, slot otherwise +byte 2 feature index from the cached map +byte 3 function index | swid function<<4 | software_id +byte 4..19 parameters (16 bytes) function-specific +``` + +When the firmware responds, it echoes bytes 0–3 (so you can match on `swid`) and writes its payload into bytes 4–19. + +## The error frame + +If the firmware can't honour your request, it sends: + +``` +byte 0 0x11 +byte 1 device index +byte 2 0x8F error feature +byte 3 echoed function | swid +byte 4 error code INVALID_FUNCTION_ID, INVALID_ARGUMENT, etc. +byte 5..19 zeros +``` + +`HIDPPTransport` catches `0x8F` automatically and surfaces it as `HIDPPError.protocolError(.invalidArgument)` in Swift. + +## Notifications + +Some features push events without a preceding request — battery low, button divert, host change. The frame layout is identical to a response except `swid = 0`. Optune dispatches notifications via a per-feature subscription closure registered with `HIDPPTransport`. + +## Capabilities, capabilities, capabilities + +Two devices of the same model can have different capability flags depending on firmware. Don't assume — call `getCapabilities` on every feature that exposes one and gate the UI on the response. + +This is why Optune greys out instead of hiding. If a feature is supported but the capability flag says "not on this firmware revision", the row stays visible with a tooltip; if the firmware doesn't expose the feature at all, the row is hidden entirely. + +## Going deeper + +The closest thing to public documentation is the **Solaar** wiki and the **logiops** project on Linux. Their feature decoders are GPL'd — Optune doesn't share code with either, but if you want to add a feature Optune doesn't have yet, those repos are the reference. diff --git a/docs/site/pages/hosts.md b/docs/site/pages/hosts.md new file mode 100644 index 0000000..e03cf66 --- /dev/null +++ b/docs/site/pages/hosts.md @@ -0,0 +1,56 @@ +--- +title: Hosts & Easy-Switch +section: Features +order: 140 +description: HostsInfo (0x1815) reads the current host slot and lets you switch between paired Macs / PCs from the menu bar. +lede: MX Master 3S, MX Keys, and MX Anywhere can pair with up to three hosts and switch with a button. Optune surfaces the slot list and lets you switch from software too. +--- + +# Hosts & Easy-Switch + +## HostsInfo (`0x1815`) + +Modern Logitech devices speak **HostsInfo** to report which host slot they're connected to and let you switch slots over HID++. + +The feature exposes: + +| Function | Returns | +|---|---| +| `GetHostInfo` | Number of slots, current slot, slot capabilities. | +| `GetHostFriendlyName(slot)` | UTF-8 name the device stored when paired (e.g. "Sanjay-mini"). | +| `SetCurrentHost(slot)` | Switch to a paired slot. The device disconnects from the current host and reconnects to the chosen one. | + +## What you see in Optune + +Settings → **Hosts** lists every slot. Each row shows: + +- Slot index (1, 2, 3) +- Friendly name from the device's flash +- Capability flags (USB / BLE / RF receiver) +- A radio button — picking a row fires `SetCurrentHost` + +The current slot is highlighted with the macOS accent color. + +## What happens when you switch + +1. Optune sends `SetCurrentHost(2)`. +2. The device acks within ~30 ms. +3. The device disconnects from your Mac. The HID interface vanishes from IOKit. +4. Optune's `IOHIDManager` matching callback fires with `removed`. The Settings pane greys out. +5. The device reconnects to host #2. + +If host #2 is *also* this Mac (e.g. you have one pairing over BLE and another over the Bolt receiver), Optune will pick the device back up within a few hundred milliseconds and the pane comes back to life. + +## Quirks + +- The **MX Master 3S** advertises 3 host slots even when only 1 is paired. Empty slots return an empty friendly name; Optune labels them **Empty slot**. +- Some firmwares return the friendly name padded with `0xFF` to fill the buffer. Optune trims any trailing `0xFF` byte sequence before decoding. +- Switching to a slot the device can't reach (host powered off) leaves the device in a "searching" state. Pressing the physical Easy-Switch button on the device cancels the search. + +## CLI + +```bash +optune hosts # list slots +optune hosts --switch 2 # switch to slot 2 +optune hosts --device "MX Keys S" # specify which keyboard if you have multiple +``` diff --git a/docs/site/pages/index.md b/docs/site/pages/index.md new file mode 100644 index 0000000..4403015 --- /dev/null +++ b/docs/site/pages/index.md @@ -0,0 +1,53 @@ +--- +title: Overview +section: Start +order: 10 +nav_order: 10 +description: Optune is a native, open-source Logitech Options+ replacement for macOS — built in Swift 6 with Liquid Glass UI. +lede: A native, open-source Logitech Options+ replacement for macOS. Optune talks to your mouse and keyboard over HID++ directly, without a daemon, an account, or a single line of telemetry. +--- + +# Overview + +## What it is + +Optune is a single ~3.8 MB universal app that pairs with your Logitech mice and keyboards over the **HID++ 2.0** protocol and exposes the bits Logi Options+ hides behind login walls and background services: + +- Live battery (with charge state and external-power detection) +- Adjustable DPI with named presets +- SmartShift toggle and sensitivity +- Per-button remapping using a 40-action catalog +- Multi-host switching for devices that support it +- Keyboard backlight, Fn-lock, and onboard slot mode +- Per-app profiles that flip pointer + scroll behaviour when you switch focus + +The whole surface is driven by **IOKit HIDManager** — no kernel extensions, no daemons, no login items, no helper services. Quit the app, every override stops. + +## What it isn't + +- **Closed source.** Optune ships the full Swift source under GPL-3.0. +- **Logged in.** There is no account, no cloud, no "Logi AI", no telemetry. +- **Cross-platform.** Optune is mac-only on purpose. Linux already has [Solaar](https://github.com/pwr-Solaar/Solaar) and Windows has the official Logitech client. We wanted a single-platform app that feels native, not a lowest-common-denominator port. + +## Architecture at a glance + +| Layer | Module | Responsibility | +|---|---|---| +| `OptuneCore` | Swift Package | Device registry, HID++ feature set, HID++ transport over IOKit | +| `OptuneApp` | App target | SwiftUI menu-bar + Settings, per-app profile engine, remap engine, notifications | +| `optune` | CLI | Scriptable wrapper around OptuneCore for diagnostics and headless use | + +The split means everything you can do in the UI you can also do from a script — `optune battery`, `optune dpi --set 1600`, `optune doctor`. The CLI uses the same `DeviceRegistry` and `HIDPP` types the app uses. + +## How it talks to devices + +Most Logitech peripherals expose two HID interfaces over Bluetooth or the Logi Bolt receiver: a standard mouse/keyboard report and a **HID++ "long" report (0x11)** that carries everything else. Optune opens the HID++ interface using **IOHIDManager**, sends a 20-byte feature request, and parses the 20-byte reply. + +There's no need for a kernel extension because IOKit gives userspace processes the right to claim a HID++ interface as long as **Input Monitoring** is granted. Optune asks for the permission once on first launch, links you straight to the right pane in System Settings, and then never asks again. + +## Where to start + +- New here? [Install](install.html) takes 30 seconds — Homebrew or DMG. +- Already running it? [Quickstart](quickstart.html) walks you through your first remap. +- Curious about a specific feature? Pick it from the sidebar — every HID++ feature has its own page with the report layout, capabilities, and quirks. +- Want to ship a contribution? [Build from source](building.html) and [adding a device](adding-a-device.html) are one-file PRs. diff --git a/docs/site/pages/install.md b/docs/site/pages/install.md new file mode 100644 index 0000000..ea6c2ef --- /dev/null +++ b/docs/site/pages/install.md @@ -0,0 +1,60 @@ +--- +title: Install +section: Start +order: 20 +description: Install Optune via Homebrew or download the DMG. Set up Input Monitoring and Accessibility permissions. +lede: Two install paths — both finish in under a minute. Pick Homebrew if you already use it, the DMG if you don't. +--- + +# Install + +## Homebrew (recommended) + +```bash +brew tap sanjays2402/optune +brew install --cask optune +``` + +The cask lives at [`Sanjays2402/homebrew-optune`](https://github.com/Sanjays2402/homebrew-optune) and auto-bumps on every GitHub release thanks to a tap-bump workflow that runs as soon as the DMG is published. Upgrades are normal `brew upgrade --cask optune`. + +## Direct download + +1. Grab the latest universal DMG from [Releases](https://github.com/Sanjays2402/optune/releases/latest) +2. Mount it and drag **Optune.app** to `/Applications` +3. The build is **ad-hoc signed** (we don't have an Apple Developer cert yet), so the first launch needs **right-click → Open** to bypass Gatekeeper. After that double-click works. + +## Permissions + +Optune needs two macOS permissions. The welcome flow links you straight to the correct System Settings pane on first launch — you don't have to hunt. + +| Permission | Why | When asked | +|---|---|---| +| **Input Monitoring** | Send HID++ feature requests over IOKit | On first launch | +| **Accessibility** | Send synthesized keystrokes for button remap | On first remap that uses `keystroke`, `mouseClick`, or `mediaKey` | + +If you skip Accessibility you can still use battery, DPI, SmartShift, hosts, onboard, and backlight. You just can't remap a button to **Cmd+C**. The Settings → Buttons pane shows a banner telling you so. + +> **Why two permissions?** Input Monitoring lets Optune talk to the device. Accessibility lets Optune *act on your behalf* — those are different trust boundaries on macOS and Apple keeps them separate. + +## Uninstall + +```bash +brew uninstall --cask optune +brew untap sanjays2402/optune +``` + +If you installed from the DMG, drag `Optune.app` to the Trash and remove its preferences: + +```bash +defaults delete com.sanjays2402.optune +rm -rf ~/Library/Application\ Support/Optune +``` + +There is no daemon, no LaunchAgent, no helper tool — uninstalling is just deleting the app. + +## Requirements + +- macOS 15 (Sequoia) or newer +- A Logitech device that speaks HID++ 2.0 over Bluetooth or a Logi Bolt receiver + +The Liquid Glass material is available on **macOS 26**; on 15 you get composited materials that look almost identical. No feature is gated by OS version. diff --git a/docs/site/pages/keyboard.md b/docs/site/pages/keyboard.md new file mode 100644 index 0000000..d871a2d --- /dev/null +++ b/docs/site/pages/keyboard.md @@ -0,0 +1,52 @@ +--- +title: Keyboard +section: Features +order: 160 +description: Backlight2 (0x1982) controls keyboard backlight modes and brightness. FnInversion (0x40A3) toggles Fn-lock. +lede: For MX Keys and friends, two HID++ features you actually care about — backlight modes and Fn-lock. Optune surfaces both, the rest stays out of your way. +--- + +# Keyboard + +## Backlight2 (`0x1982`) + +The Backlight2 feature controls per-key illumination on the MX Keys family. Three writes: + +| Function | Effect | +|---|---| +| `GetBacklightConfig` | Returns supported modes, current mode, current intensity. | +| `SetBacklightConfig(mode, level)` | Sets mode and a 0–100 intensity. | +| `Subscribe` | Optune subscribes so the panel updates if you press the backlight ▲/▼ keys directly. | + +Modes the firmware exposes (subset varies by model): + +- **Off** — backlight always off, ignores intensity +- **On** — always on at the configured intensity +- **Reactive** — fades up briefly when you type, fades back down +- **Adaptive** — uses the keyboard's ambient light sensor (MX Keys S only) + +Settings → **Keyboard** has a row per mode plus an intensity slider. The slider is hidden for **Off** and **Adaptive** because those modes don't honour it. + +## FnInversion (`0x40A3`) + +Fn-lock decides whether the F-keys default to **media** (volume, brightness) or **function** (F1–F12). Without Fn-lock, you have to hold the Fn key to flip the meaning. With it, the meaning is inverted permanently. + +The HID++ feature gates this with two flags: + +- **invertible** — false on cheap keyboards, true on MX Keys +- **inverted** — current state + +If `invertible` is false, Optune greys out the toggle and explains why ("This keyboard's firmware does not expose Fn-lock"). Some Logi keyboards have a physical Fn-lock LED instead — the toggle on those is `Fn + Esc`, not anything Optune can drive. + +## Multi-keyboard + +If you have two Logi keyboards connected (e.g. an MX Keys S at the desk and an MX Keys Mini in the bag), Settings → Keyboard shows a picker at the top. Each keyboard remembers its own backlight mode + Fn-lock state. + +## CLI + +```bash +optune backlight # show mode + intensity +optune backlight --mode reactive --level 60 +optune fnlock --enable +optune fnlock --disable +``` diff --git a/docs/site/pages/onboard.md b/docs/site/pages/onboard.md new file mode 100644 index 0000000..9313f17 --- /dev/null +++ b/docs/site/pages/onboard.md @@ -0,0 +1,58 @@ +--- +title: Onboard & Profiles +section: Features +order: 150 +description: OnboardProfiles (0x8100) controls host vs onboard mode and the active onboard slot. Persist DPI/buttons across machines without an app. +lede: Onboard mode lets your mouse keep its DPI, button bindings, and SmartShift settings without any host software running. Optune lets you switch between host and onboard mode and pick the active slot. +--- + +# Onboard & Profiles + +## OnboardProfiles (`0x8100`) + +The mouse has two operating modes: + +- **Host mode** — DPI, button bindings, SmartShift come from whatever software is running on the connected host (Optune, Options+, or nothing). +- **Onboard mode** — the mouse uses settings stored in its own flash. Three slots (or five on some devices), each with DPI, buttons, SmartShift, polling rate. + +Onboard mode is what makes a mouse useful when you plug it into a machine that doesn't have your software. Optune's job here is to: + +1. Tell you which mode you're in +2. Let you switch +3. Show which onboard slot is active + +## Mode switch + +Settings → **Onboard** has a segmented control: **Host / Onboard**. Switching writes via `setMode(mode)`. The change is immediate. + +When the device is in **onboard mode**: + +- DPI presets (the [Pointer pane](pointer.html)) stop working — `setSensorDpi` returns `INVALID_ARGUMENT`. Optune detects this and shows a banner. +- Button remaps **also** stop working — the device honours its onboard bindings instead. Optune shows the same banner in Buttons. +- SmartShift and Hosts still work normally — those features ignore the host/onboard split. + +## Slot picker + +In onboard mode, Optune shows three slot buttons. Picking one writes `setActiveProfile(slot)` and the device flashes its slot LED to confirm. + +The slot **contents** (which DPI presets, which button bindings each slot holds) are read-only in v0.6. Writing onboard slot content needs the `ReprogControlsV4` onboard write report types, which we've reverse-engineered but haven't shipped yet — that lands in v0.8. + +## Why this matters + +The single most useful real-world workflow: + +1. Pair the mouse with your work Mac (host slot 1) and your home Mac (host slot 2) +2. Configure DPI presets and button remaps on each Mac with Optune +3. Onboard slot 1 mirrors the work Mac's settings; slot 2 mirrors home +4. When you walk to a Mac that doesn't have Optune installed (a meeting room, a friend's machine), switch to **onboard mode + slot 1** and your mouse behaves the same as on your work Mac + +That's the closed-source Logi Options+ flow, replicated in 80 lines of Swift. + +## CLI + +```bash +optune onboard # show current mode + slot +optune onboard --mode onboard # switch to onboard +optune onboard --mode host +optune onboard --slot 2 # pick onboard slot 2 +``` diff --git a/docs/site/pages/permissions.md b/docs/site/pages/permissions.md new file mode 100644 index 0000000..a08d99d --- /dev/null +++ b/docs/site/pages/permissions.md @@ -0,0 +1,52 @@ +--- +title: Permissions & TCC +section: Start +order: 40 +description: How Optune uses Input Monitoring and Accessibility, what each unlocks, and how to recover after a code-sign change invalidates them. +lede: macOS gates HID and synthetic input behind two separate Transparency, Consent, and Control (TCC) prompts. Here's exactly what each unlocks and how to recover when an upgrade invalidates them. +--- + +# Permissions & TCC + +## The two prompts + +Optune asks for **Input Monitoring** and **Accessibility**. They sound similar; they aren't. + +| Permission | Surface | Without it | +|---|---|---| +| **Input Monitoring** | All HID++ feature reads/writes — battery, DPI, SmartShift, button enumeration, host switching, backlight, Fn-lock, onboard mode. | The app launches but every device shows "?" for battery and the Settings panes are empty. | +| **Accessibility** | Synthesized keystrokes (`keystroke`), mouse clicks (`mouseClick`), and media keys (`mediaKey`) — i.e. button remap actions that have to forge events. | DPI presets, SmartShift, hosts still work. Custom button bindings that produce real key events don't. The Buttons pane shows a banner. | + +## Why two separate boundaries + +macOS has been splitting "watch what the user is doing" from "act on the user's behalf" since macOS 10.15. Input Monitoring is the read-side gate — it lets us see and talk to HID interfaces. Accessibility is the write-side gate — it lets us forge events into the system. + +If Apple ever folds them together you'll just see one prompt; until then we ask for the smaller permission first and the larger one only when a feature actually needs it. + +## Recovering after a re-sign + +The first time it'll bite you is the first upgrade. Each time the Optune binary is **code-signed** the OS treats it as a new identity, and TCC throws out the old grant. You'll see: + +- Battery pills go to "?" +- Buttons stop responding to remaps + +Fix it once and it sticks until the next re-sign: + +1. Open **System Settings → Privacy & Security → Input Monitoring** +2. Find **Optune**. If it's checked, **uncheck and re-check** it. +3. Repeat for **Accessibility** if you use button remap. + +Optune ships an **AccessibilityChecker** that runs on every launch — if either grant is stale you'll see a banner with a one-click jump button straight to the right pane. v0.6 added the same check for Input Monitoring after `OPTU-22` reported the silent failure. + +> **Future fix:** once we have an Apple Developer ID + notarisation (paid Developer Program), the binary identity stops shifting between releases and TCC remembers it across upgrades. Until then, expect the dance once per release. + +## Headless / CI + +The `optune` CLI uses the same `IOHIDManager` path, so it also needs Input Monitoring. On a headless host (no Login window, no Finder), the standard workflow is: + +```bash +sudo tccutil reset SystemPolicyAllFiles ~/Library/Application\ Support/com.apple.TCC/TCC.db +# Then run `optune doctor` once interactively to trigger the prompt. +``` + +Once granted, the grant survives reboots. There is no LaunchAgent — `optune` is invoked on demand. diff --git a/docs/site/pages/pointer.md b/docs/site/pages/pointer.md new file mode 100644 index 0000000..8824761 --- /dev/null +++ b/docs/site/pages/pointer.md @@ -0,0 +1,64 @@ +--- +title: Pointer & DPI +section: Features +order: 110 +description: How AdjustableDPI (HID++ 0x2201) works, how Optune named presets bind to the firmware sensor range. +lede: Optune uses AdjustableDPI (0x2201) to read your sensor's range and write a new value instantly. Presets are stored per-device and per-app. +--- + +# Pointer & DPI + +## The feature: AdjustableDPI (`0x2201`) + +`AdjustableDPI` is the HID++ feature that exposes the sensor's resolution range and lets you set a new value without rebooting the device. It's universal across MX Master generations, MX Anywhere, and MX Vertical. + +Three function calls: + +| Function | What it returns | +|---|---| +| `GetSensorCount` | Number of independent sensors (always 1 on consumer devices). | +| `GetSensorDpiList` | Min/max DPI in steps the firmware accepts. | +| `GetSensorDpi` / `SetSensorDpi` | Current value and the setter. | + +## How presets work + +Optune ships three named presets per device: **Low / Med / High**. The values are computed from the firmware-reported range: + +- **Low** = `range.min` +- **High** = `range.max` +- **Med** = midpoint, rounded to the nearest legal step + +So an MX Master 3S with a `[200, 8000]` range gets presets at **200 / 4000 / 8000**. An MX Anywhere 3 with `[200, 4000]` gets **200 / 2000 / 4000**. + +You can edit the values per device in **Settings → Pointer**. They're stored in `~/Library/Preferences/com.sanjays2402.optune.plist` keyed by the device's `productID`. + +## Cycle DPI from a button + +In the Buttons pane, the **Cycle DPI** action rotates through your presets. Each press goes Low → Med → High → Low. The action fires through the [Action Catalog](buttons.html), no special wiring needed. + +When you cycle, Optune emits a brief **HUD overlay** with the new value so you don't have to look at the menu bar to confirm. + +## Per-app DPI + +Per-app profiles store a DPI value distinct from the global one. When the focused app changes, Optune's `NSWorkspace.didActivateApplicationNotification` observer: + +1. Looks up the bundle ID in the profile store +2. If found, applies the profile's DPI via `setSensorDpi` +3. If not, applies the global default + +The flip is debounced 50 ms so rapidly Cmd-Tabbing doesn't thrash the firmware. + +## CLI + +```bash +optune dpi +optune dpi --set 1600 +optune dpi --device "MX Master 3S" --set 4000 +``` + +`optune dpi` with no arguments prints the current value and the firmware-reported range. + +## Sensor caveats + +- **Onboard mode** locks DPI to one of the three onboard slots. If the device is in onboard mode, `setSensorDpi` is rejected with `INVALID_ARGUMENT`. Optune detects this and shows a banner saying "switch to host mode to use DPI presets" — see [Onboard](onboard.html). +- The **MX Vertical** has a quirk where setting DPI under 400 disables tracking entirely until reset. Optune clamps the slider min to 400 for that device family. diff --git a/docs/site/pages/profiles.md b/docs/site/pages/profiles.md new file mode 100644 index 0000000..365b321 --- /dev/null +++ b/docs/site/pages/profiles.md @@ -0,0 +1,85 @@ +--- +title: Per-app profiles +section: Features +order: 170 +description: Optune flips DPI, SmartShift, scroll mode, and button bindings when you switch focus. Powered by NSWorkspace activation observers. +lede: One global profile, plus an unlimited number of per-app overrides. The flip happens the instant you Cmd-Tab — no polling, no daemon, no reload. +--- + +# Per-app profiles + +## How it works + +Optune subscribes to `NSWorkspace.shared.notificationCenter` for `didActivateApplicationNotification`. Each time a different app comes to the front, the notification handler runs: + +```swift +let bundleID = app.bundleIdentifier ?? "" +let profile = profileStore.profile(for: bundleID) ?? globalProfile +deviceModel.apply(profile) +``` + +`apply(profile)` is the same code path the Settings UI uses to push a new value, so there's no special "profile flip" pipeline to break. Whatever the UI can change, profiles can override. + +## What a profile holds + +A profile is a Swift struct with optional fields. **Only fields you set get applied.** Anything left nil falls back to the global profile. + +| Field | What it overrides | +|---|---| +| `dpi: Int?` | The active DPI preset value. | +| `smartShiftEnabled: Bool?` | Whether SmartShift is on. | +| `smartShiftThreshold: UInt8?` | The threshold value if SmartShift is on. | +| `scrollMode: ScrollMode?` | Forces ratchet, free-spin, or smart. | +| `bindings: [Control: RemapAction]?` | Per-button overrides for this app. | + +The store is `~/Library/Application Support/Optune/profiles.json`, keyed by bundle ID. You can edit the JSON directly if you want — Optune watches the file with a Dispatch source and reloads on change. + +## Common patterns + +**Final Cut Pro / Logic Pro** — boost DPI and force ratchet for precise scrubbing: + +```json +{ + "com.apple.FinalCut": { + "dpi": 6400, + "scrollMode": "ratchet", + "smartShiftEnabled": false + } +} +``` + +**Safari / Chrome** — bind the back button to "Back" (instead of Mission Control): + +```json +{ + "com.apple.Safari": { + "bindings": { + "back": { "kind": "keystroke", "keyCode": 123, "modifiers": ["cmd"] } + } + } +} +``` + +**Xcode** — heavy keystroke sets (Build, Run, Stop) on the gesture button: + +```json +{ + "com.apple.dt.Xcode": { + "bindings": { + "gesture_up": { "kind": "keystroke", "keyCode": 11, "modifiers": ["cmd"] } + } + } +} +``` + +## Debouncing + +Cmd-Tab between two apps fast and macOS fires multiple activation events in milliseconds. Optune debounces with a 50 ms window — only the **last** activation in any 50 ms burst triggers a profile flip. This keeps the firmware from getting hammered. + +## Picker + +Settings → **Per-App Profiles** → **+** opens a system app picker. The picker filters to running + installed apps and remembers your last choice. Bundle IDs are stored — renaming or moving the app doesn't break the profile. + +## Export / import + +Cmd-Shift-E exports your **entire** Optune state — global profile, all per-app profiles, button bindings, presets — to a JSON file. Cmd-Shift-I imports one. Useful for migrating to a new Mac without reconfiguring. diff --git a/docs/site/pages/quickstart.md b/docs/site/pages/quickstart.md new file mode 100644 index 0000000..d8f38ea --- /dev/null +++ b/docs/site/pages/quickstart.md @@ -0,0 +1,54 @@ +--- +title: Quickstart +section: Start +order: 30 +description: Two minutes from install to your first custom button remap. +lede: Two minutes from install to your first remap. We'll plug in a mouse, set a DPI preset, and bind the thumb button to "Mission Control". +--- + +# Quickstart + +## 1. Pair the device + +If your mouse came with a Logi Bolt receiver, plug it in. Otherwise pair over Bluetooth via System Settings → Bluetooth. **Optune does not ship its own pairing UI** — macOS handles that, and Optune picks up whatever is paired. + +## 2. Open Optune + +```bash +open -a Optune +``` + +A dot appears in the menu bar. Click it. You should see your device with a battery percentage, a DPI pill, and a SmartShift pill. + +> If the device is missing, run `optune doctor` from the terminal. It dumps every HID interface IOKit knows about and tells you whether HID++ responded. + +## 3. Set a DPI preset + +In the menu-bar dropdown, click your device → **Pointer**. Drag the slider, or pick from the dropdown. Optune writes via `setSensitivity()` on `AdjustableDPI` (`0x2201`) so the change is instant and persists across sleep cycles. + +Three named presets ship by default — **Low / Med / High** — anchored to your device's sensor range from `devices.json`. You can edit the preset values in **Settings → Pointer**. + +## 4. Remap the thumb button + +Open **Settings** (Cmd+,) → **Buttons**. Optune queries `ReprogControlsV4` (`0x1B04`) once and lists every remappable control with its native label and a dropdown. + +Find the **Back** button (the lower thumb button on an MX Master). Click the dropdown. The new **Action Catalog** shows 40+ named actions grouped by category: + +- **Window** — Mission Control, App Exposé, Show Desktop, Launchpad +- **Edit** — Cut, Copy, Paste, Undo, Redo +- **Navigation** — Forward, Back, Page Up/Down, Home, End +- **Media** — Play/Pause, Next, Previous, Volume, Brightness +- **Mouse** — Left/Right/Middle Click, Cycle DPI, Toggle SmartShift, Toggle Scroll Mode +- **System** — Spotlight, Notification Center, Lock Screen, Force Quit + +Pick **Mission Control**. The change is live — press the thumb button and Mission Control pops up. Optune used a `CGEventTap` to intercept the original button press and a `CGEvent` (or `NSEvent.otherEvent` for media keys) to fire the new one. + +## 5. Make it stick to apps + +Settings → **Per-App Profiles** → click **+**. Pick an app (e.g. Final Cut Pro). The profile inherits your global remap; tweak whatever you want, save, and the next time Final Cut comes to the front Optune flips DPI, SmartShift, scroll mode, and bindings to match. + +The flip happens via an `NSWorkspace` activation observer, so there's no polling — it's instant when you Cmd-Tab. + +## You're done + +Five things in two minutes. The rest of the docs go deep on each surface. Pick the one you care about from the sidebar. diff --git a/docs/site/pages/roadmap.md b/docs/site/pages/roadmap.md new file mode 100644 index 0000000..1880854 --- /dev/null +++ b/docs/site/pages/roadmap.md @@ -0,0 +1,42 @@ +--- +title: Roadmap +section: About +order: 300 +description: What's shipped, what's next, what's on the wishlist. +lede: Three columns — shipped, in flight, on the wishlist. Optune isn't done; the Logi parity gap is closing one feature per minor release. +--- + +# Roadmap + +## Shipped + +| Version | Headline | +|---|---| +| **v0.5** | Per-app profiles, keyboard backlight + Fn-lock, onboard mode, settings export/import, welcome flow, in-app updates | +| **v0.6** | Homebrew tap (`sanjays2402/optune`) with auto-bump, Accessibility (TCC) gate fix, sidebar Settings, eight HID++ features, custom button remap, in-app updater, GitHub Releases poller | +| **v0.7** | Action Catalog with 40+ named actions, single-instance guard, RemapActionDispatcher singleton bridge | + +## In flight (v0.8) + +- **SmoothScroll (`0x2121`)** — high-resolution scroll wheel events for fine in-app adjustment +- **Onboard slot writes** — push DPI / button bindings into onboard slots so the mouse keeps your config when plugged into a host without Optune +- **Gesture button state machine** — multi-direction gesture chords (currently we only divert single events) +- **Search bar in catalog menu** — 40+ actions deserves filterable selection in Settings → Buttons + +## Wishlist (v1.0+) + +- **Apple Developer ID + notarisation** — so TCC grants survive upgrades, no more re-toggle dance after `brew upgrade --cask optune` +- **Localised strings** — start with German, French, Japanese; Optune already wraps every UI string in `String(localized:)` +- **Optune Lite (CLI-only Homebrew formula)** — `brew install optune` for the headless / server crowd; ships only the CLI, no .app bundle +- **More devices** — every PR-able registry entry welcome (see [adding a device](adding-a-device.html)) +- **Linux port?** — *No.* Solaar is excellent. Optune is Mac-only on purpose. + +## Anti-roadmap + +Things that will never ship in Optune: + +- **Telemetry / analytics** — not anonymised, not opt-in, not "for stability metrics". None. +- **Account login** — pair the device, configure it, done. +- **Cloud profile sync** — JSON export / import is the sync mechanism. iCloud Drive handles the rest. +- **Auto-update background daemon** — the in-app updater asks before downloading. No LaunchAgents. +- **Cross-platform UI framework** — every line of UI is SwiftUI. If a feature is awkward in SwiftUI, we make SwiftUI work, not paper over it with Electron. diff --git a/docs/site/pages/troubleshooting.md b/docs/site/pages/troubleshooting.md new file mode 100644 index 0000000..9137c02 --- /dev/null +++ b/docs/site/pages/troubleshooting.md @@ -0,0 +1,61 @@ +--- +title: Troubleshooting +section: Reference +order: 240 +description: Common Optune problems and how to fix them — battery shows ?, button remap not firing, device missing. +lede: Most issues fall into one of five buckets. Skim the headings, find your symptom, fix it. If yours isn't here, optune doctor is the right next step. +--- + +# Troubleshooting + +## "Battery shows ?" + +Means the HID++ feature call timed out or the firmware returned `0xFF`. + +1. **Check Input Monitoring.** System Settings → Privacy & Security → Input Monitoring → Optune is checked. If unchecked, toggle it; if checked, **uncheck and re-check** to refresh the TCC grant after an upgrade. +2. **Check the device pairing.** Some BLE devices renegotiate after wake. Move the mouse, wait 5 seconds, watch the pill. +3. **Check `optune doctor`.** It dumps every HID interface IOKit knows about. If your device is listed but `featureSet` is empty, it's a HID++ handshake failure — usually a firmware quirk on the older MX Master 2S. + +## "Button remap doesn't fire" + +The button physically clicks but nothing happens in macOS. + +1. **Check Accessibility.** System Settings → Privacy & Security → Accessibility → Optune. Required for `keystroke`, `mouseClick`, and `mediaKey` actions. +2. **Are you in onboard mode?** Onboard mode honours the device's flash bindings, not Optune's. Switch to host mode in Settings → Onboard. +3. **Did the divert ack fail?** Run with `--verbose` and look for `setRemap … divert=1` followed by `INVALID_ARGUMENT`. Some buttons on some firmwares refuse divert — Optune greys those rows out, but a stale UI cache might still show them as bindable. + +## "DPI presets aren't applying" + +The slider moves but the cursor speed doesn't change. + +1. **Onboard mode** — same as above. Onboard mode locks DPI. +2. **Per-app override** — check Settings → Per-App Profiles. The active app's profile may pin DPI to a value distinct from the global slider. +3. **Driver conflict** — if you have **Logi Options+** installed, it can race Optune for the HID++ interface. Quit Options+ entirely (`launchctl unload` + `pkill -i logi`) before testing. + +## "Device disappears after wake" + +macOS occasionally drops BLE devices on wake. Optune subscribes to `IOHIDManager` re-attach events, so the device should reappear within seconds. If it doesn't: + +1. Wiggle the mouse — BLE wake-on-motion takes a frame. +2. **Forget and re-pair** in System Settings → Bluetooth. Some firmwares get into a bad bond state after sleep / wake on multiple hosts. + +## "App won't open after upgrade" + +If macOS shows "Optune is damaged and can't be opened": + +```bash +xattr -d com.apple.quarantine /Applications/Optune.app +``` + +The DMG is ad-hoc signed (no Developer ID). Gatekeeper sometimes flags re-signed apps after the first launch. Removing the quarantine xattr fixes it without compromising security — you're still launching the same hash you downloaded. + +## Filing a bug + +Run `optune doctor --verbose 2>&1 | tee optune-doctor.log` and attach the log. Include: + +- Optune version (`optune --version`) +- macOS version (`sw_vers`) +- Device name and connection (BLE / Bolt / USB cable) +- Repro steps + +[Open an issue](https://github.com/Sanjays2402/optune/issues/new) — I read every one. diff --git a/docs/site/pages/wheel.md b/docs/site/pages/wheel.md new file mode 100644 index 0000000..bce0b1f --- /dev/null +++ b/docs/site/pages/wheel.md @@ -0,0 +1,50 @@ +--- +title: Wheel & SmartShift +section: Features +order: 120 +description: SmartShift (HID++ 0x2111) toggles between ratcheted and free-spin scroll. Optune lets you bind it to a button and tweak the auto-shift threshold. +lede: SmartShift is the magic that flips your scroll wheel from clicky to free-spin when you flick it hard. Optune exposes the threshold and lets you bind the toggle to any button. +--- + +# Wheel & SmartShift + +## SmartShift (`0x2111`) + +Logitech's MX wheels can run in two modes: + +- **Ratcheted** — clicky, resistance, one notch per scroll event +- **Free-spin** — frictionless, multiple events per flick, the wheel keeps spinning after you let go + +SmartShift detects how hard you flick the wheel and switches mode automatically. The HID++ feature exposes: + +| Field | What it does | +|---|---| +| `enabled` | Master on/off. Off = always ratcheted. | +| `threshold` (1–255) | Higher = needs a harder flick to switch to free-spin. Default ~50. | + +## Toggle from a button + +The Action Catalog includes **Toggle Scroll Mode**. Bind it to any button and pressing it flips between ratcheted-locked and free-spin-locked, ignoring SmartShift. Press again and SmartShift takes over. + +A second action, **Toggle SmartShift**, enables/disables SmartShift itself. Useful if you find the auto-switch annoying — bind it to the wheel-down click (`MiddleClick`) and you can turn the heuristic off without opening Settings. + +## Threshold UI + +Settings → **Wheel** has a slider for `threshold`. The slider is mapped non-linearly so the useful 30–80 range gets most of the travel. Hover the slider for a live preview — Optune writes the value, lets you flick the wheel, then restores the previous value when you release. + +## Wheel ratchet mode (`0x2150`) + +For devices that have a **HiResScroll / RatchetSwitch** feature in addition to SmartShift, Optune wires up the same UI but writes through `0x2150` instead. The two features are mutually exclusive on a single wheel — `DeviceRegistry` knows which one each model exposes. + +## SmoothScroll (`0x2121`) — pending + +SmoothScroll is the high-resolution scrolling protocol that lets the host get sub-line increments. macOS has its own pixel-level scroll handling, so SmoothScroll mostly matters for in-app fine adjustment in apps like Logic Pro. We've reverse-engineered the report layout (it's in `HIDPP+SmoothScroll.swift` as a stub) but the v0.7 release ships SmartShift only — full SmoothScroll lands in v0.8. + +## CLI + +```bash +optune smartshift # show current state +optune smartshift --enable +optune smartshift --threshold 60 +optune smartshift --disable +```