diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index feb48f4..a60b745 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -14,7 +14,7 @@ body: attributes: label: Notro version description: Right-click the tray icon; the version is in the tooltip. Or check Settings → Apps. - placeholder: "2.10.1" + placeholder: "2.10.3" validations: required: true @@ -40,7 +40,7 @@ body: attributes: label: Steps to reproduce placeholder: | - 1. Copy a 15 MB PNG + 1. Copy a 24 MB PNG 2. Wait for the tray notification 3. Paste in Discord validations: @@ -78,5 +78,5 @@ body: Close Notro, then run it once from a terminal with logging on: `set NOTRO_DEBUG=%TEMP%\notro.log` followed by `"%LOCALAPPDATA%\Programs\Notro\Notro.exe"`. Reproduce the problem, then paste the contents of `%TEMP%\notro.log` here. - The log contains window coordinates only — no clipboard content. + Review and redact the log before sharing; do not post account tokens, personal paths or private messages. render: text diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e1e8da3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Problem and user impact + +Explain what is broken or missing, including a concrete scenario. + +## Implementation + +Describe the changed files, API/UI behavior and whether this changes +clipboard/Windows settings, network calls, user data or installed artifacts. + +## Evidence (do not invent results) + +- [ ] Python tests and Ruff passed on Windows +- [ ] Python dependency audit completed +- [ ] Media threshold preflight passed (if relevant) +- [ ] Native WebView2 test passed (if UI changed) +- [ ] Real Windows file dialogs tested (if dialog changed) +- [ ] Privacy, keyboard and accessibility reviewed +- [ ] All five languages and README instructions updated, or N/A +- [ ] Public Discord integration tested in a non-sending draft, or + **clearly marked NOT TESTED** +- [ ] Installer runtime/upgrade/uninstall checked, or clearly marked NOT TESTED +- [ ] No Discord client code modified, injected or bundled; no REA runtime use +- [ ] No sensitive user files, tokens or private media in commits/screenshots + +## Release impact and known gaps + +State what users can actually download now versus unreleased code. +Do not use CI success as a proxy for successful installation, Discord +account-policy compliance or permission to reuse marketing media. + +**Release/public marketing is a separate maintainer action.** +Installers are unsigned by project decision; do not start signing workflows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e02206..6bb0fd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,5 +16,13 @@ jobs: cache: pip - name: Install dependencies run: pip install -r requirements-dev.txt + - name: Audit build/runtime dependencies + run: | + pip install pip-audit==2.10.1 + python -m pip_audit -r requirements-build.txt --strict + - name: Lint Python + run: ruff check notro_app tests scripts + - name: Release media threshold preflight + run: python scripts/qa_release_media_preflight.py - name: Run tests run: python -m pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03e7a39..6ff6d65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v7 + with: + # Required for trustworthy tagged-commit ancestry check. + fetch-depth: 0 - uses: actions/setup-python@v7 with: @@ -23,7 +26,13 @@ jobs: cache: pip - name: Install dependencies - run: pip install --upgrade pyinstaller -r requirements-dev.txt + run: pip install -r requirements-build.txt + + - name: Audit build/runtime dependencies + run: python -m pip_audit -r requirements-build.txt --strict + + - name: Release media threshold preflight + run: python scripts/qa_release_media_preflight.py - name: Export app version shell: pwsh @@ -37,6 +46,16 @@ jobs: } "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Verify release tag belongs to main + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + git fetch origin main --quiet + git merge-base --is-ancestor $env:GITHUB_SHA origin/main + if ($LASTEXITCODE -ne 0) { + throw "Refusing release: tagged commit is not in origin/main" + } + - name: Verify release tag matches app version if: startsWith(github.ref, 'refs/tags/') run: python check_release_version.py "$env:GITHUB_REF_NAME" @@ -62,6 +81,29 @@ jobs: - name: Run tests run: python -m pytest -q + # Real own WebView2 windows, synthetic local images and no user data. + # Verifies first-use buttons and independent video dialog bridge. + - name: QA native welcome Try Picker and dismiss + if: github.event_name == 'workflow_dispatch' + run: python scripts/qa_native_welcome_flow.py + + - name: QA native video confirm trim and cancel + if: github.event_name == 'workflow_dispatch' + run: python scripts/qa_native_video_dialog.py + + # Accesses only disposable runner's clipboard, with synthetic media; + # never the maintainer's live Windows clipboard or Discord account. + - name: QA native Win32 clipboard on disposable runner + if: github.event_name == 'workflow_dispatch' + run: python scripts/qa_runner_clipboard_cf_hdrop.py + + # The target window is owned by the disposable hosted runner only. + # This exercises PickerController -> Win32 CF_HDROP -> SendInput -> own + # Tk text field, never Discord or a user's live desktop/clipboard. + - name: QA own-window Win32 Ctrl+V and focus recovery + if: github.event_name == 'workflow_dispatch' + run: python scripts/qa_runner_owned_target_paste.py + # --collect-all webview가 numpy·cryptography까지 끌어온다 (앱은 둘 다 쓰지 않는다). # build.bat과 같은 제외 플래그를 유지할 것 — 어긋나면 CI 산출물만 40MB 커진다. - name: Build EXE @@ -77,12 +119,35 @@ jobs: run: | & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer.iss /DAppVersion=$env:APP_VERSION + # Short-lived, unsigned QA in a fresh Windows GitHub-hosted runner. + # No installed client data or global clipboard are touched. + - name: QA install and uninstall on ephemeral runner + if: github.event_name == 'workflow_dispatch' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/qa_installer_smoke.ps1 -ExpectedVersion $env:APP_VERSION + - name: Generate SHA256 shell: pwsh run: | $hash = (Get-FileHash dist/NotroSetup.exe -Algorithm SHA256).Hash.ToLower() "$hash NotroSetup.exe" | Out-File -FilePath dist/NotroSetup.exe.sha256 -Encoding ascii -NoNewline + # On-demand QA build: short-lived UNSIGNED installer, no release/tag. + # Access depends on repository permissions; never market this as signed. + - name: Upload unsigned QA installer + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: NOTRO-UNSIGNED-QA-${{ github.run_id }} + path: | + dist/NotroSetup.exe + dist/NotroSetup.exe.sha256 + retention-days: 3 + if-no-files-found: error + overwrite: false + # 태그 푸시일 때만 릴리스를 만든다. 덕분에 workflow_dispatch로 돌리면 빌드와 # installer 컴파일만 검증하고 릴리스는 만들지 않는다(installer.iss 안전 검증용). - name: Publish release diff --git a/CHANGELOG.md b/CHANGELOG.md index 7304228..779490b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,96 @@ All notable changes to this project are documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [2.11.0] - Unreleased (candidate: 2026-10-13) + +### Added +- Windows notification-area right-click opens an owned Discord-inspired dark + WebView2 panel with picker shortcuts, monitoring controls and safe settings. + About displays the real program version, GitHub/Release/Issues, MIT, + platform and unsigned/independent disclosure. Native pystray menu and + About dialog remain available if WebView2 is missing or not yet ready. + The UI is localized to English, Korean, Japanese, Chinese and Spanish; + actual native WebView2 popup/screenshots were regression tested. +- Portable user-controlled ZIP emoji/sticker/GIF pack import/export with content + verification, size limits and duplicate detection. Private Captures and original + source URLs are excluded. This is **not** a full settings backup. +- First-run welcome offers a direct, explicit "Try picker" action. +- Added separate opt-in JSON preferences backup with explicit watched-folder + path disclosure, strict parsing, user review and two-step restoration. + No images/Captures, and auto-send and auto-capture are disabled on restore. + Real native Windows Save/Open with Korean filenames tested. + +- On-demand release-workflow builds can retain a 3-day UNSIGNED QA installer + artifact with its SHA-256, enabling real Windows installer tests without + publishing a release/tag. +- Synthetic 100/1,000/5,000-item library performance benchmark, Windows regression + coverage and Ruff CI lint. REA static compatibility and marketing QA notes. + +- Opt-in GIF size/quality preview: right-click an animated item to compare + original and optimized animations, frame timing, dimensions and file sizes. + A bounded Pillow engine tries palette reductions before lowering resolution, + preserves animation loop and frame timing, and rejects unsafe inputs. + Only the user-approved temporary candidate is pasted; automatic Enter is + suppressed. Original GIFs and library files remain unchanged. +- GIF preview lifecycle includes cancel/close cleanup and temporary localhost + media URLs. Native WebView2 fixture validates both images and a synthetic + approve/cancel flow without touching live Discord, user clipboard or chat. + +### Fixed +- Native WebView2 Welcome and video confirmation dialogs: moved native + WinForms Window references into private API fields. Exposing the GUI + object through a public JavaScript bridge attribute caused recursive + AccessibilityObject traversal and could prevent first-use and video + buttons from becoming responsive. Actual native "Try Picker"/Dismiss, + video trim/mute/replan/confirm/close now pass. +- Corrupt or unsupported media ZIPs, nested invalid metadata and unsafe + settings JSON are rejected without mutating the media library. Bad + library.json/folders.json files are backed up byte-for-byte as local + .bak files so the application can recover without deleting cached media. +- ZIP archive outer size is bounded before reading central-directory + metadata, in addition to existing manifest and decompressed image caps. +- The Discord free upload default now matches the official August 2026 20 MB + limit. Existing explicit 10 MB choices remain available. The 95% safety margin + now targets **compressed output** rather than triggering needless compression + of 19–20 MB files. +- Watched folder JPEG files appear correctly beside PNG, GIF and WebP. +- Local media server streams GIF assets in bounded chunks; race handling returns + 404 if a watched file vanishes before opening. +- Collection folder names cannot resolve to ancestor paths or Windows DOS devices. + +### Notes +- Real Discord/WebView2 installer end-to-end QA and signing are **not yet complete**. + Do not publish this candidate release until the verification gates are met. + +### Further hardening (release candidate; not yet published) +- Optional auto-send now requires a still-focused Discord desktop target and + checks the original window again before both paste and Enter. Ordinary + paste to other apps still works, but Enter is never automatic there. This + uses public Windows APIs only and cannot detect switching channels + inside one window; auto-send remains disabled by default. +- Local picker thumbnail URLs now require an unpredictable per-session + capability and the exact loopback Host header. ZIP manifest decompression + is bounded even for untrusted compressed input. +- Added isolated native Windows Edge WebView2 fixture smoke that verifies real + thumbnails, picker search/settings, all five language bridges and ZIP drop + import using synthetic data, without opening chats or modifying Discord. +- Paste safety: checks the original window BEFORE modifying the clipboard, + preserves any newer copy made while preparing media and verifies that + Windows accepted Ctrl+V key events before reporting success. + When focus is already lost, the clipboard is not changed. Included + five-language explanatory notifications and a separately owned + Win32 target-window QA harness on a disposable Windows runner. +- A single ZIP media pack may now be dragged onto the picker to import via + the same validated media pack path used by the explicit Import button. +- Added repeatable native Win32 Save As/Open dialog E2E on Korean ZIP + filenames and an isolated WebView2 fixture, including cancel paths. +- Measured synthetic 100/1,000/5,000-item Windows WebView2 DOM render and + search without touching user images. Real mixed GIF memory QA remains. +- Prefer current pywebview FileDialog enum with old-version-only fallback, + eliminating deprecated alias warnings. +- Plane backlog synchronization now preserves existing work-item state, priority + and ownership so restarting a session cannot silently reset QA progress. + ## [2.10.3] - 2026-08-08 ### Fixed diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md index 77afbd3..8a2cdbe 100644 --- a/CODE_SIGNING.md +++ b/CODE_SIGNING.md @@ -1,11 +1,11 @@ # Code signing policy -> **Status: not signed yet.** Releases of Notro are currently unsigned, so Windows -> SmartScreen warns that the publisher is unknown. See -> [SECURITY.md](SECURITY.md#code-signing) for how to verify a download in the -> meantime. This document states the policy that governs signing, and is a -> prerequisite for applying to the [SignPath Foundation](https://signpath.org/) -> free certificate programme for open source projects. +> **Current policy (2026-10-14): no code signing planned.** The maintainer +> explicitly chose unsigned distribution. Windows SmartScreen may warn about +> an unknown publisher. See SECURITY.md for integrity checks and how to +> inspect/build from source. A SHA-256 file does not prove publisher identity. +> The historical SignPath investigation below is archived context, not an +> application or an instruction to pursue signing. ## Project @@ -26,10 +26,18 @@ Notro is maintained by one person, who therefore holds all three roles: |---|---| | Author — writes and modifies code | [@writingdeveloper](https://github.com/writingdeveloper) | | Reviewer — approves changes before they reach `main` | [@writingdeveloper](https://github.com/writingdeveloper) | -| Approver — authorises a release to be signed | [@writingdeveloper](https://github.com/writingdeveloper) | +| Release approver — reviews unsigned binary and SHA-256 before publication | [@writingdeveloper](https://github.com/writingdeveloper) | + +Enable multi-factor authentication on the GitHub maintainer account. + +## Historical signing research (not in scope) + +Signing is deliberately deferred. Do not initiate SignPath/Azure accounts, +request certificates or alter branch restrictions solely for code signing. +Independent CI/security QA and release authorization still matter. +The archived research note is at +docs/research/2026-10-13-notro-code-signing-readiness.md. -Multi-factor authentication is required on the GitHub account and on the signing -service account. ## How releases are built @@ -43,7 +51,7 @@ beside it. Anyone can reproduce a build from source with ## Binary metadata -Every signed artifact carries consistent product and version metadata: +Every unsigned release artifact still carries consistent product and version metadata: - `Notro.exe` — `ProductName: Notro`, `ProductVersion`/`FileVersion` generated from `notro_app.__version__` by [`build_version_file.py`](build_version_file.py). @@ -70,12 +78,3 @@ circumvent any security measure. It prepares the Windows clipboard and simulates local Ctrl+V — the same class of input automation as the built-in Windows emoji panel. It does not modify, patch, or inject into the Discord client, and never accesses an account or token. - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d13ebd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to Notro + +Notro is a small, community-maintained **Windows-only clipboard utility**. It +is **not affiliated with or endorsed by Discord**, and its emoji are image +attachments rather than native Nitro emoji. + +## Start here + +Read [README.md](README.md), [SECURITY.md](SECURITY.md), +[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) and +[CHANGELOG.md](CHANGELOG.md) before changing Windows hooks or distribution. + +The repo has no GITHUB.md or AGENTS.md instruction file. This document and +the source/tests are the current contributor entry points. + +## Working on a change + +- Use a feature branch or Git worktree. Do not overwrite someone else's + uncommitted changes or modify their running Discord installation. +- **Never bundle, embed, launch or inject REA into Notro.** REA may be used + by developers for independent compatibility research where lawful; Notro + itself must use public Windows APIs and user-directed clipboard/paste. + Do not ship proprietary Discord code or captured account content. +- Never access authentication tokens, private messages, user library + directories, or the global clipboard in automated tests without explicit + isolation/consent. +- Add a focused regression test before marking a behavioral bug complete. +- Keep English, Korean, Japanese, Chinese, and Spanish UI strings aligned; + update corresponding README files when the public behavior changes. +- Treat auto-send, auto-capture, startup, backup/import, downloader and + updater behaviors as high-risk. Keep unsafe operations opt-in and never + silently turn them on while importing settings. + +## Reproduce the engineering checks + +Run from a supported Windows 10/11 development environment with Python 3.12+ +and Microsoft Edge WebView2: + +1. Install project dependencies: pip install -r requirements-build.txt +2. Check Python style: ruff check notro_app tests scripts +3. Run unit tests: python -m pytest -q +4. Test real-byte synthetic threshold behavior: + python scripts/qa_release_media_preflight.py +5. When a change touches the picker, run the isolated, non-Discord + Windows WebView2 UI harness: python scripts/qa_native_webview.py +6. If file dialogs changed, run the separate synthetic native dialog harness: + python scripts/qa_native_file_dialogs.py +7. When Welcome, video dialog, ZIP or settings import changes, run + isolated native Windows WebView2 UI tests: + python scripts/qa_native_welcome_flow.py + python scripts/qa_native_video_dialog.py + python scripts/qa_native_file_dialogs.py + The last test opens 10 real Windows file dialogs (including malformed + ZIP and JSON), never using real Notro settings or Discord data. +8. Run dependency audit: python -m pip_audit -r requirements-build.txt --strict + +The browser/WebView2 harness uses synthetic inputs and must not be +described as proof of live Discord integration. Actual user-driven +attachment draft tests, installer runtime, Windows DPI, accessibility +and updated OS versions require separate test evidence. + +## Reporting bugs and security issues + +Use [the bug report template](.github/ISSUE_TEMPLATE/bug_report.yml). +**Never attach Discord tokens, private chat screenshots, private media, +unredacted logs, or personal local file paths** to a public report. +For vulnerabilities, use [private security reporting](SECURITY.md). + +## Proposed pull requests + +Submit a short purpose statement, implementation overview, Windows +test evidence, privacy/network/storage effects, localized UI changes +and remaining unsolved cases. Use the PR template to separate actual +test results from simulations. + +Do **not** merge, tag, publish a release, auto-update user installations, +post public promotions, or sign installers merely because CI is green. +A human maintainer must approve each release. The project is explicitly +not pursuing code signing; any published unsigned installer must be +prominently labeled and treated as a trust limitation. diff --git a/README.es.md b/README.es.md index 5a84e80..b9442db 100644 --- a/README.es.md +++ b/README.es.md @@ -21,7 +21,7 @@ Dos cosas por las que Discord cobra, resueltas desde fuera de la aplicación: -- **Tu imagen pesa 14 MB y el límite gratuito es de 10 MB.** Notro lo nota en cuanto la +- **Tu imagen pesa 24 MB y el límite gratuito es de 20 MB.** Notro lo nota en cuanto la copias, la reduce y la devuelve al portapapeles. Tú solo pulsas Ctrl+V. - **Quieres usar emojis personalizados en cualquier parte.** Pulsa Ctrl+Shift+E, elige uno y aparece en el cuadro de mensaje. @@ -33,10 +33,10 @@ Nunca modifica el cliente de Discord ni ve tu cuenta o tu token. La contrapartid quien lo recibe ve tus emojis como imágenes adjuntas, no como emojis integrados.
-
+
Interfaz real, biblioteca real: filtra al escribir, las flechas recorren y Enter pega.
+Captura real del selector WebView2 de Windows con figuras geométricas originales en una biblioteca temporal. Muestra búsqueda, teclado, pestañas y ajustes; no demuestra adjuntos de Discord ni una instalación real.
⬇ Descargar NotroSetup.exe
@@ -45,6 +45,28 @@ quien lo recibe ve tus emojis como imágenes adjuntas, no como emojis integrados
---
+> Según la [FAQ de archivos de Discord](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ), actualizada el 30 de septiembre de 2026, y la [FAQ de Nitro](https://support.discord.com/hc/en-us/articles/115000435108-What-are-Nitro-Nitro-Basic): 20 MB gratis, 50 MB Nitro Basic y hasta 1 GB Nitro. Se mantienen las opciones manuales antiguas de 10 y 500 MB. El límite real puede variar según la cuenta.
+
+## Menú de la bandeja y About (v2.11.0 candidato)
+
+**Todavía no disponible en la versión pública v2.10.3.** Con clic derecho
+sobre el icono de Notro junto al reloj, la próxima versión mostrará un
+panel propio con colores oscuros inspirados en Discord. Desde ahí podrás
+abrir el selector, pausar la supervisión, abrir la biblioteca y editar
+límite de carga, idioma, atajo, inicio y actualizaciones.
+
+**Acerca de Notro** muestra la versión real del programa, autor, sistemas
+Windows compatibles, WebView2, licencia MIT y enlaces a GitHub, Releases
+e Issues. Es una aplicación **sin firma** e independiente de Discord.
+Sin WebView2 se mantiene el menú clásico de Windows y el cuadro About.
+
+
+
+
+
+Capturas de un WebView2 real con datos sintéticos para QA, no de una
+instalación personal ni de un chat de Discord.
+
## El selector
Ctrl+Shift+E en cualquier sitio abre una ventana oscura
@@ -83,24 +105,54 @@ cierra. Después del atajo no hace falta soltar el teclado.
portapapeles — desactivado de forma predeterminada.
- La ventana se puede redimensionar y recuerda su tamaño, teniendo en cuenta el DPI del
monitor.
-- Elementos que superan el límite: las imágenes fijas se comprimen automáticamente y los
- GIF demasiado grandes se envían tal cual con un aviso.
+- Elementos que superan el límite: las imágenes fijas se comprimen automáticamente;
+ los GIF grandes se mantienen intactos y muestran un aviso. Haz clic derecho en
+ una animación → **Optimizar GIF…** para comparar el original y la versión reducida,
+ incluidos tamaño y resolución. Solo se pega tras aceptar explícitamente;
+ el envío es manual y ni el original ni la biblioteca se modifican.
- Hay un envío automático opcional que pulsa Enter por ti después de pegar —
desactivado de forma predeterminada.
+ Solo funciona si Discord de escritorio sigue enfocado; en otras aplicaciones debes enviar manualmente. No detecta cambios de canal dentro de la misma ventana.
+ Antes de reemplazar el portapapeles comprueba que la ventana original siga enfocada; si copias algo nuevo mientras prepara la imagen, **conserva esa copia y cancela**. Verificar que Windows aceptó Ctrl+V no garantiza que la aplicación haya adjuntado el archivo.
+## Importar y exportar paquetes de medios
+
+En los ajustes del selector puedes importar o exportar como ZIP tus emojis, stickers y GIF
+registrados, con nombres y palabras clave. Al importar se comprueban el formato, los hashes
+y las rutas, y se omiten los duplicados.
+
+No se incluyen Captures privados, URL originales de Discord, historial del portapapeles,
+rutas de carpetas supervisadas ni ajustes de Windows. No es una copia completa de los ajustes.
+Comparte solo medios cuya distribución esté permitida.
+
+También puedes arrastrar un ZIP de paquete al selector: se valida igual que con el botón Importar paquete.
+
+## Copia y restauración de preferencias (JSON)
+
+Exportar ajustes crea un JSON local con idioma, atajos, límites de subida
+y tamaños del selector. No contiene imágenes, capturas privadas, historial
+del portapapeles ni cuentas de Discord. Las rutas absolutas de carpetas
+vigiladas se excluyen por defecto salvo permiso explícito.
+
+Restaurar ajustes muestra una revisión y necesita otra confirmación.
+Nunca borra carpetas existentes y solo puede añadir rutas presentes.
+El envío y la captura automáticos quedan desactivados; el inicio de
+Windows permanece sin cambios. Reinicia Notro para aplicar idioma y atajos.
+Las imágenes se transfieren con el ZIP de medios por separado.
+
## Compresión automática
Notro vigila el portapapeles. Cuando aparece una imagen nueva calcula **el tamaño del PNG
que Discord produciría realmente**, y si cabe dentro del límite no hace nada. Si lo supera,
recodifica a WebP y después a JPEG, bajando la calidad y por último la resolución hasta
-quedar por debajo de unos 9,5 MB, y devuelve el resultado **como archivo** para que
+quedar por debajo de unos 19 MB, y devuelve el resultado **como archivo** para que
Ctrl+V lo suba como adjunto.
> Tus archivos originales en disco nunca se tocan — solo se reemplaza el portapapeles.
-El límite se cambia desde la bandeja entre **10 / 50 / 500 MB** (gratis, Nitro Basic, Nitro).
+El límite se cambia desde la bandeja entre **10 / 20 / 50 / 500 MB** (10 MB opcional, gratis, Nitro Basic, Nitro).
## Clips de vídeo
@@ -108,12 +160,12 @@ Copia un clip de juego demasiado grande y Notro pregunta primero, mostrando exac
esperar:
```
-52MB · 1:12 · 1080p60 → unos 9,5MB · 480p30
+52MB · 1:12 · 1080p60 → unos 19MB · 720p60
```
En esa misma ventana puedes **recortarlo** (`inicio – fin`) y **quitarle el audio**, y la
estimación se actualiza mientras escribes. Esto pesa más que cualquier ajuste del
-codificador: una captura de 30 segundos a 1080p60 tiene que bajar a **1080p30** como clip
+codificador: una captura de 30 segundos a 1080p60 tiene que bajar a **720p60** como clip
entero, pero recortada a los siete segundos que realmente querías se queda en **1080p60**.
**ffmpeg nunca se incluye en el paquete.** Se descarga bajo demanda (unos 30 MB, verificado
@@ -191,8 +243,8 @@ El comportamiento de la compresión está en `notro_app/config.py` y `notro_app/
| Ajuste | Predeterminado | Descripción |
|---|---|---|
-| `LIMIT_MB` | 10 | Límite de subida — o elige 10/50/500 desde el menú de la bandeja |
-| `SAFETY` | 0.95 | Margen de seguridad (apunta a unos 9,5 MB) |
+| `LIMIT_MB` | 20 | Límite de subida — o elige 10/20/50/500 desde el menú de la bandeja |
+| `SAFETY` | 0.95 | Margen de seguridad (apunta a unos 19 MB) |
| `WEBP_QUALITIES` | 90–50 | Pasos de calidad de WebP |
| `MIN_SCALE` | 0.4 | Límite inferior de reducción de resolución |
diff --git a/README.ja.md b/README.ja.md
index 8173290..d92652a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -21,7 +21,7 @@
Discord が課金対象にしている 2 つのことを、アプリの外側から解決します。
-- **画像が 14MB あるのに無料の上限は 10MB。** コピーした瞬間に Notro が縮小して
+- **画像が 24MB あるのに無料の上限は 20MB。** コピーした瞬間に Notro が縮小して
クリップボードへ戻します。あとは Ctrl+V を押すだけです。
- **カスタム絵文字をどこでも使いたい。** Ctrl+Shift+E を
押して選べば、入力欄に入ります。
@@ -33,10 +33,10 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か
ネイティブのインライン絵文字ではなく画像の添付として見えます。
-
+
実際の UI と実際のライブラリ — 入力に応じて絞り込み、方向キーで移動、Enter で貼り付け。
+オリジナルの幾何学サンプルを一時ライブラリに登録して撮影した実際の Windows WebView2 UI。検索・キーボード操作・タブ・設定のデモであり、Discord 添付やインストールのデモではありません。
⬇ NotroSetup.exe をダウンロード
@@ -45,6 +45,28 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か
---
+> 2026年9月30日更新の[Discord公式添付FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ)と[Nitro FAQ](https://support.discord.com/hc/en-us/articles/115000435108-What-are-Nitro-Nitro-Basic)では、無料20MB・Nitro Basic 50MB・Nitro最大1GBです。従来の10MB・500MBも手動で選択できます。利用者によって上限が異なる場合があります。
+
+## トレイメニューと About(開発中の v2.11.0)
+
+**現行公開版 v2.10.3 には未搭載**です。時計の横にある Notro の
+通知領域アイコンを右クリックすると、Discord 風のダーク配色を使った
+独立した Notro メニューを表示します。ピッカー、監視の一時停止、
+ライブラリ、容量上限、言語・ショートカット・起動・更新設定、
+**Notro について**を開けます。
+
+About には実際のプログラムバージョン、発行元、対応 Windows、
+WebView2、MIT ライセンスと GitHub・Releases・Issues が表示されます。
+未署名で Discord とは公式の関係がありません。WebView2 のない環境では
+従来の Windows メニューとネイティブの About を利用できます。
+
+
+
+
+
+これは合成データを使った実際の WebView2 の QA キャプチャです。
+Discord のチャット画面や一般ユーザーのインストール結果ではありません。
+
## ピッカー
どこでも Ctrl+Shift+E を押すと、カーソルの近くに暗い
@@ -79,33 +101,60 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か
- クリップボードボタンで現在の画像を **キャプチャ** コレクションに直接保存できます。
新しいクリップボード画像を自動保存する設定もあり、初期値はオフです。
- ウィンドウはサイズ変更でき、その大きさを(モニターの DPI も考慮して)記憶します。
-- 上限を超える項目: 静止画は自動的に圧縮し、大きすぎる GIF は警告つきでそのまま送ります。
+- 上限を超える項目: 静止画は自動で圧縮します。大きなGIFは初期状態ではそのままのため、
+ 警告を表示します。アニメーションを右クリック → **GIFを最適化…** で元の動きと
+ 最適化候補のサイズ・解像度を比較でき、ユーザーが承認した候補のみ貼り付けます。
+ 送信は手動で、元のファイルやライブラリは変更しません。
- 貼り付けたあと Enter まで代わりに押す自動送信オプションがあります — 初期値はオフ。
+ フォーカスが続いている Discord デスクトップだけが対象です。他のアプリでは手動で送信してください。同じウィンドウ内でのチャンネル変更は検知できません。
+ クリップボードを変更する前に元のウィンドウのフォーカスを確認します。画像の準備中に新しいコピーが行われた場合は **新しいコピーを保持して貼り付けを中止** します。Windows がキー入力を受け付けても、相手側アプリでの添付完了は保証されません。
+## メディアパックのインポート・エクスポート
+
+ピッカーの設定から、登録した絵文字・スタンプ・GIF と検索用の名前・キーワードを ZIP
+形式で書き出し、別の端末に読み込めます。読み込み前にファイル形式・ハッシュ・パスを検証し、重複を除外します。
+
+個人用の Captures、Discord の元 URL、クリップボード履歴、監視フォルダーのパス、Windows の設定は
+含まれません。設定全体のバックアップではありません。共有権限があるメディアのみ配布してください。
+
+パックの ZIP をピッカーへ直接ドロップして読み込めます。設定メニューからの読み込みと同じ検証を行います。
+
+## 設定バックアップ・復元(JSON)
+
+ピッカーの設定から言語、ショートカット、容量上限や表示サイズを
+JSON に保存できます。画像や個人キャプチャ、Discord アカウント情報は
+含まれません。監視フォルダーの絶対パスは初期状態で除外されます。
+
+復元前に項目数を確認し、別の承認ボタンを押して適用します。
+既存フォルダーは削除せず、存在するパスのみ任意で追加できます。
+自動送信・自動キャプチャは復元後もオフで、Windows 起動設定は
+変更しません。言語とショートカットには再起動が必要です。
+画像は別のメディアパック ZIP で移行してください。
+
## 自動圧縮
Notro はクリップボードを見張っています。新しい画像が現れると **Discord が実際に生成する
PNG の容量** を計算し、上限内なら何もしません。超えていれば WebP → JPEG の順に再エンコード
-して品質を、それでも足りなければ解像度を下げ、約 9.5MB 以下に収めた結果を **ファイルとして**
+して品質を、それでも足りなければ解像度を下げ、約 19MB 以下に収めた結果を **ファイルとして**
クリップボードに載せます。Ctrl+V でそのまま添付されます。
> ディスク上の元ファイルには一切触れません — 置き換えるのはクリップボードだけです。
-上限はトレイから **10 / 50 / 500MB**(無料・Nitro Basic・Nitro)に切り替えられます。
+上限はトレイから **10 / 20 / 50 / 500MB**(10MB 任意・無料・Nitro Basic・Nitro)に切り替えられます。
## 動画クリップ
大きすぎるゲームクリップをコピーすると、Notro はまず確認し、どうなるかを示します:
```
-52MB · 1:12 · 1080p60 → 約 9.5MB · 480p30
+52MB · 1:12 · 1080p60 → 約 19MB · 720p60
```
同じウィンドウで **範囲を切り出し**(`開始 – 終了`)**音声を外す** ことができ、入力するたびに
-予測が更新されます。これはどんなエンコーダー設定よりも効きます: 30 秒の 1080p60 の映像は
-まるごとだと **1080p30** まで落ちますが、実際に必要だった 7 秒だけ残せば **1080p60** を
+予測が更新されます。これはどんなエンコーダー設定よりも効きます: 90 秒の 1080p60 の映像は
+まるごとだと **720p60** まで落ちますが、実際に必要だった 7 秒だけ残せば **1080p60** を
保てます。
**ffmpeg は同梱しません。** 初めて動画を圧縮するときに必要な分だけ取得し(約 30MB、
@@ -179,8 +228,8 @@ Windows と Python 3.10 以上が必要です。
| 設定 | 既定値 | 説明 |
|---|---|---|
-| `LIMIT_MB` | 10 | アップロード上限 — トレイメニューで 10/50/500 も選べます |
-| `SAFETY` | 0.95 | 安全マージン(約 9.5MB を目標) |
+| `LIMIT_MB` | 20 | アップロード上限 — トレイメニューで 10/20/50/500 も選べます |
+| `SAFETY` | 0.95 | 安全マージン(約 19MB を目標) |
| `WEBP_QUALITIES` | 90–50 | WebP の品質段階 |
| `MIN_SCALE` | 0.4 | 解像度縮小の下限 |
diff --git a/README.ko.md b/README.ko.md
index e82e522..58e6294 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -19,9 +19,9 @@
---
-디스코드가 돈을 받는 두 가지를, 앱 바깥에서 해결합니다.
+디스코드의 자주 겪는 불편 두 가지를, 클라이언트 수정 없이 해결합니다.
-- **이미지가 14MB인데 무료 한도는 10MB입니다.** 복사하는 순간 Notro가 알아서 줄여
+- **이미지가 24MB인데 무료 한도는 20MB입니다.** 복사하는 순간 Notro가 알아서 줄여
클립보드에 되돌려 놓습니다. Ctrl+V만 누르면 됩니다.
- **커스텀 이모지를 어디서든 쓰고 싶습니다.** Ctrl+Shift+E를
누르고 고르면 입력창에 들어갑니다.
@@ -33,10 +33,10 @@
네이티브 인라인 이모지가 아니라 이미지 첨부로 보입니다.
-
+
실제 UI, 실제 라이브러리 — 입력하는 대로 걸러지고, 방향키로 이동, Enter로 붙여넣기.
+직접 만든 도형 테스트 이미지로 촬영한 실제 Windows WebView2 선택기입니다. 검색·키보드·탭·설정을 보여주며 Discord 첨부·설치 과정 영상은 아닙니다.
⬇ NotroSetup.exe 내려받기
@@ -45,6 +45,29 @@
---
+> 2026년 9월 30일 갱신된 [Discord 공식 첨부파일 FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ) 및 [Nitro 안내](https://support.discord.com/hc/en-us/articles/115000435108-What-are-Nitro-Nitro-Basic): 무료 20MB, Nitro Basic 50MB, Nitro 최대 1GB. 기존 10MB·500MB 선택지도 유지합니다. 사용자별 실험에 따라 실제 한도가 다를 수 있습니다.
+
+## 트레이 메뉴 및 About (v2.11.0 개발 후보)
+
+**현재 공개된 v2.10.3에는 아직 없고**, 개발 중인 v2.11.0 후보에 추가된
+기능입니다. **시계 옆 Notro 트레이 아이콘을 우클릭**하면 Discord풍
+다크 테마의 **Notro 자체 메뉴**가 열립니다. 피커, 감시 중지, 라이브러리,
+업로드 한도, 단축키·언어·시작 프로그램·업데이트 설정과 **Notro 정보**로
+이동할 수 있습니다.
+
+About에서는 **실제 프로그램 버전**, 개발자, 지원 Windows,
+WebView2 엔진, MIT 라이선스와 GitHub·Releases·Issues 링크를 확인할 수
+있습니다. Notro는 **미서명 Windows 프로그램**이며 Discord 공식 제품이
+아닙니다. WebView2가 없거나 준비 전에는 기본 Windows 트레이 메뉴와
+기본 About 정보 창을 사용합니다.
+
+
+
+
+
+위 화면은 임시 테스트 데이터로 촬영한 **실제 WebView2 화면**이며,
+사용자 컴퓨터의 설치본이나 Discord 채팅을 촬영한 것은 아닙니다.
+
## 피커
어디서든 Ctrl+Shift+E를 누르면 커서 옆에 어두운 팝업이
@@ -77,33 +100,62 @@
- 클립보드 버튼을 누르면 현재 클립보드 이미지가 **캡처** 컬렉션에 바로 저장됩니다. 피커
설정에서 새 클립보드 이미지를 자동 저장할 수도 있고, 기본값은 꺼짐입니다.
- 창 크기를 조절할 수 있고 그 크기를 기억합니다(모니터 배율까지 감안).
-- 한도를 넘는 항목: 정지 이미지는 자동 압축, 초과 GIF는 경고와 함께 원본 전송.
+- 한도를 넘는 항목: 정지 이미지는 자동 압축합니다. 큰 GIF는 기본적으로 원본 유지와
+ 경고를 제공하며, 애니메이션 항목을 우클릭 → **GIF 용량 최적화…**하면 움직이는
+ 원본과 후보의 크기·해상도를 비교할 수 있습니다. 직접 승인한 후보만 붙여넣으며,
+ 전송은 수동입니다. 원본 파일과 라이브러리는 수정하지 않습니다.
- 붙여넣은 뒤 Enter까지 대신 누르는 자동 전송 옵션이 있습니다 — 기본 꺼짐.
+ 같은 Discord 데스크톱 창에 포커스가 계속 있을 때만 작동하며, 다른 앱에서는 직접 전송해야 합니다. 동일 창 안에서 채널 변경까지 감지하지는 못합니다.
+ 붙여넣기 전 원래 창의 포커스를 확인하고, 이미지 준비 도중 사용자가 새 내용을 복사하면 **새 클립보드를 보존하고 작업을 취소**합니다. Windows 키 입력 승인 여부도 검사하지만 상대 프로그램의 첨부 성공까지 보장하지는 않습니다.
+## 미디어 팩 가져오기·내보내기
+
+피커 설정에서 **팩 내보내기** 또는 **팩 가져오기**를 선택하면 이모지·스티커·GIF
+항목과 검색용 이름·키워드를 ZIP으로 옮길 수 있습니다. 가져오기 전에 파일 형식·해시·경로를
+검증하고 중복 항목은 건너뜁니다.
+
+개인 **캡처** 컬렉션, Discord 원본 URL, 클립보드 기록, 감시 폴더 경로와 Windows 설정은
+내보내지 않습니다. 전체 설정 백업이 아니며, 공유할 권리가 있는 미디어만 전달하세요.
+
+가져올 팩 ZIP 파일을 선택기 위로 끌어다 놓아도 됩니다. 설정의 팩 가져오기 버튼과 동일한 검증을 거칩니다.
+
+## 환경설정 백업·복원 (JSON)
+
+피커 설정의 **설정 백업**에서 언어·단축키·업로드 한도·선택기 및 붙여넣기
+크기를 JSON으로 저장합니다. 이미지·클립보드 기록·개인 캡처·Discord 계정
+데이터는 포함하지 않습니다. **감시 폴더 절대경로는 기본 제외**이며
+체크한 경우에만 저장합니다.
+
+**설정 복원…**은 설정과 폴더 개수를 미리 보여주고 별도 승인 후
+적용합니다. 기존 폴더를 삭제하지 않고 실제로 존재하는 경로만
+선택적으로 추가합니다. **자동 전송과 자동 캡처는 복원 후에도 꺼지며**
+Windows 시작 프로그램 설정은 유지됩니다. 언어와 단축키 적용에는
+Notro 재시작이 필요합니다. 이미지는 별도 미디어 팩 ZIP으로 옮기세요.
+
## 자동 압축
Notro는 클립보드를 지켜봅니다. 새 이미지가 들어오면 **디스코드가 실제로 만들어 낼 PNG
용량**을 계산하고, 한도 안이면 아무 일도 하지 않습니다. 넘으면 WebP → JPEG 순으로
-다시 인코딩하며 품질을, 그래도 안 되면 해상도를 낮춰 ~9.5MB 아래로 맞춘 뒤, 결과를
+다시 인코딩하며 품질을, 그래도 안 되면 해상도를 낮춰 ~19MB 아래로 맞춘 뒤, 결과를
**파일로** 클립보드에 올려 Ctrl+V가 첨부로 올라가게 합니다.
> 디스크의 원본 파일은 절대 건드리지 않습니다 — 클립보드만 교체합니다.
-한도는 트레이에서 **10 / 50 / 500MB**(무료·니트로 베이직·니트로)로 바꿀 수 있습니다.
+한도는 트레이에서 **10 / 20 / 50 / 500MB**(10MB 선택값·무료·니트로 베이직·니트로)로 바꿀 수 있습니다.
## 비디오 클립
너무 큰 게임 클립을 복사하면 Notro가 먼저 물어보면서, 어떻게 될지를 보여줍니다:
```
-52MB · 1:12 · 1080p60 → 약 9.5MB · 480p30
+52MB · 1:12 · 1080p60 → 약 19MB · 720p60
```
같은 창에서 **구간을 자르고**(`시작 – 끝`) **오디오를 뺄** 수 있으며, 입력하는 대로 예상치가
-갱신됩니다. 이게 어떤 인코더 설정보다 중요합니다: 30초짜리 1080p60 영상은 통째로는
-**1080p30**으로 떨어지지만, 실제로 원했던 7초만 남기면 **1080p60**을 그대로 유지합니다.
+갱신됩니다. 이게 어떤 인코더 설정보다 중요합니다: 90초짜리 1080p60 영상은 통째로는
+**720p60**으로 떨어지지만, 실제로 원했던 7초만 남기면 **1080p60**을 그대로 유지합니다.
**ffmpeg는 앱에 넣지 않습니다.** 처음 비디오를 압축할 때 필요한 만큼만 내려받고(~30MB,
SHA-256 검증), `PATH`에 이미 있으면 그것을 씁니다. 360p로도 한도에 못 들어가는 클립은
@@ -175,8 +227,8 @@ Windows + Python 3.10 이상이 필요합니다.
| 설정 | 기본값 | 설명 |
|---|---|---|
-| `LIMIT_MB` | 10 | 업로드 한도 — 트레이 메뉴에서 10/50/500 선택 가능 |
-| `SAFETY` | 0.95 | 안전 마진 (약 9.5MB 목표) |
+| `LIMIT_MB` | 20 | 업로드 한도 — 트레이 메뉴에서 10/20/50/500 선택 가능 |
+| `SAFETY` | 0.95 | 안전 마진 (약 19MB 목표) |
| `WEBP_QUALITIES` | 90–50 | WebP 품질 단계 |
| `MIN_SCALE` | 0.4 | 해상도 축소 하한 |
diff --git a/README.md b/README.md
index 7786a84..0199857 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,9 @@
---
-Two things Discord charges for, solved from outside the app:
+Two everyday Discord frustrations, handled without modifying the Discord client:
-- **Your image is 14 MB and the free limit is 10 MB.** Notro notices the moment you copy
+- **Your image is 24 MB and the free limit is 20 MB.** Notro notices the moment you copy
it, shrinks it, and puts it back on your clipboard. You just press Ctrl+V.
- **You want to use custom emoji anywhere.** Press Ctrl+Shift+E,
pick one, and it lands in the message box.
@@ -33,10 +33,10 @@ Discord client and never sees your account or token. The honest trade-off: recip
your emoji as image attachments, not as native inline emoji.
-
+
Real UI, real library — search filters as you type, arrow keys move, Enter pastes.
+Actual Windows WebView2 picker UI using a temporary library of original generated geometric assets. Shows search, keyboard focus, tabs and settings; NOT a Discord attachment or installed-app demo.
⬇ Download NotroSetup.exe
@@ -45,6 +45,29 @@ your emoji as image attachments, not as native inline emoji.
---
+> [Discord's File Attachments FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ) (updated September 30, 2026) and [Nitro FAQ](https://support.discord.com/hc/en-us/articles/115000435108-What-are-Nitro-Nitro-Basic): Free 20 MB / Nitro Basic 50 MB / Nitro up to 1 GB. The 10 MB and 500 MB custom presets remain available. Actual limits can vary by account and experiments. The optional 1 GB preset conservatively uses decimal 1,000,000,000 bytes.
+
+## Tray menu and About (v2.11.0 candidate)
+
+**New in the unreleased v2.11.0 candidate** (not in public v2.10.3):
+right-click the **Notro notification-area icon beside the clock** to open a
+dark, Discord-inspired **Notro-owned** popup. From there you can open the
+picker, pause monitoring, open the library, change upload limit/language/
+hotkey/startup/update preferences, and choose **About Notro**.
+
+About displays the **actual installed program version**, publisher, Windows
+compatibility, WebView2 engine, MIT license and hardcoded GitHub repository,
+Releases and Issues links. The product is **unsigned** and independent of,
+not endorsed by, Discord. When WebView2 is missing/not ready, the original
+Windows tray context menu remains usable and provides a native About dialog.
+
+
+
+
+
+These screenshots were captured from an isolated synthetic native WebView2
+fixture, **not** a normal installed end-user session or Discord chat.
+
## The picker
Ctrl+Shift+E anywhere opens a dark popup at your cursor
@@ -80,35 +103,67 @@ collection with no registration step at all.
collection. Picker settings can do that automatically for every new clipboard image —
off by default.
- The window can be resized and remembers its size, per monitor DPI.
-- Items over the upload limit: still images are compressed automatically, oversized GIFs
- are sent as-is with a warning.
+- Items over the upload limit: still images are compressed automatically. Oversized GIFs
+ are unchanged by default and show a warning. For an animated item, right-click →
+ **Optimize GIF…** to compare the original with an explicitly generated smaller GIF
+ (color/resolution changes shown). Only **Paste optimized** uses the candidate;
+ sending remains manual. Your original file and library are never modified.
- Optional auto-send presses Enter for you after pasting — off by default.
+ It is limited to a still-focused Discord desktop window; pasted items in other apps must be sent manually. Switching channels inside the same window cannot be detected.
+ The picker now checks the original window **before** modifying the clipboard. If you copy something new while an image is prepared, it cancels rather than overwriting that copy. Windows input acceptance is checked but cannot guarantee that a receiving app accepts the attachment.
+## Portable media packs
+
+In the picker settings, choose **Export pack** or **Import pack** to move your
+registered emoji, sticker and GIF library between Windows PCs. Packs are local
+ZIP files with media and searchable labels, checked for corrupt files and
+unsafe paths before import. Already saved duplicates are skipped.
+
+Private automatic **Captures**, Discord source URLs, clipboard history, watched
+folder paths and Windows settings are **not exported**. The ZIP is a portable
+asset pack, not a complete settings backup. Share only media you have rights to.
+
+Drop a pack ZIP directly onto the picker for quick import, or use the Import pack button in Settings.
+
+## Preferences backup (JSON)
+
+From Picker Settings, **Export settings** saves a separate local JSON file
+with language, hotkey, upload limit and display/paste-size preferences.
+It contains no images, clipboard history, Captures, Discord account data or
+source URLs. **Watched-folder absolute paths are excluded by default**;
+include them only when explicitly checked.
+
+**Restore settings** first shows the number of settings and paths and requires
+a second approval to apply. Existing watched folders are never deleted; you
+may optionally add paths that still exist. **Auto-send and auto-capture remain
+OFF** after restoration, and Windows startup is unchanged. Restart Notro for
+language/hotkey changes. Use a separate media pack ZIP to transfer images.
+
## Automatic compression
Notro watches the clipboard. When a new image appears it computes **the PNG size Discord
would actually produce**, and if that fits the limit it does nothing at all. Over the
limit, it re-encodes to WebP, then JPEG, lowering quality and finally resolution until it
-fits under ~9.5 MB, and puts the result back as a file so Ctrl+V
+fits under ~19 MB, and puts the result back as a file so Ctrl+V
uploads it as an attachment.
> Your original files on disk are never touched — only the clipboard is replaced.
-The limit is switchable from the tray for **10 / 50 / 500 MB** (free, Nitro Basic, Nitro).
+The limit is switchable from the tray for **10 / 20 / 50 / 500 MB** (10 MB optional low cap, free, Nitro Basic, Nitro).
## Video clips
Copy a game clip that's too big and Notro asks first, showing exactly what to expect:
```
-52MB · 1:12 · 1080p60 → about 9.5MB · 480p30
+52MB · 1:12 · 1080p60 → about 19MB · 720p60
```
You can **trim it** (`start – end`) and **drop the audio** in that same window, and the
estimate updates as you type. This matters more than any encoder setting: a 30-second
-1080p60 capture has to fall back to **1080p30** as a whole clip, but trimmed to the seven
+1080p60 capture has to fall back to **720p60** as a whole clip, but trimmed to the seven
seconds you actually wanted, it stays at **1080p60**.
**ffmpeg is never bundled.** It's fetched on demand (~30 MB, SHA-256 verified) the first
@@ -124,7 +179,7 @@ installs to `%LOCALAPPDATA%\Programs\Notro`.
> unknown — click *More info → Run anyway*. Every release ships a `NotroSetup.exe.sha256`
> so you can verify exactly what you downloaded, and you can
> [build it yourself](#build-it-yourself) from source. See
-> [SECURITY.md](SECURITY.md#code-signing) and the [code signing policy](CODE_SIGNING.md).
+> [SECURITY.md](SECURITY.md#code-signing) and the [Code signing policy](CODE_SIGNING.md).
**Windows 10 users:** the picker needs the Microsoft Edge WebView2 runtime (Windows 11 has
it built in). The installer fetches it if it's missing. Without it the picker is disabled
@@ -183,8 +238,8 @@ Compression behaviour lives in `notro_app/config.py` and `notro_app/compress.py`
| Setting | Default | Description |
|---|---|---|
-| `LIMIT_MB` | 10 | Upload limit — or pick 10/50/500 from the tray menu |
-| `SAFETY` | 0.95 | Safety margin (targets ~9.5 MB) |
+| `LIMIT_MB` | 20 | Upload limit — or pick 10/20/50/500 from the tray menu |
+| `SAFETY` | 0.95 | Safety margin (targets ~19 MB) |
| `WEBP_QUALITIES` | 90–50 | WebP quality steps |
| `MIN_SCALE` | 0.4 | Lower bound for downscaling |
@@ -197,6 +252,15 @@ pytest
+## Contributing and public release
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for contributor setup, Windows
+regression tests, responsible issue reporting and why a green CI alone
+does not authorize a public installer release or marketing campaign.
+The project has chosen **not to pursue code signing**; its unsigned
+Windows installer may trigger a publisher warning. Never disable
+Windows protections just to try an unsigned download.
+
## License
[MIT](LICENSE). Bundled third-party components and their licenses — including pystray,
diff --git a/README.zh.md b/README.zh.md
index ab6c5f8..a7eb0cb 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -21,7 +21,7 @@
Discord 要收费的两件事,在应用之外解决:
-- **图片有 14MB,免费上限是 10MB。** 你复制的那一刻 Notro 就把它压好放回剪贴板,
+- **图片有 24MB,免费上限是 20MB。** 你复制的那一刻 Notro 就把它压好放回剪贴板,
你只要按 Ctrl+V。
- **想在任何地方用自定义表情。** 按 Ctrl+Shift+E
挑一个,它就进到输入框里。
@@ -32,10 +32,10 @@ Discord 要收费的两件事,在应用之外解决:
接收方看到的是图片附件,而不是原生的内嵌表情。
-
+
真实界面、真实收藏库 —— 输入即筛选,方向键移动,Enter 粘贴。
+这是使用临时媒体库和自行绘制的几何素材在 Windows WebView2 中录制的真实 Notro 选择器界面,演示搜索、键盘、切换标签和设置;并非 Discord 附件上传或安装程序的演示。
⬇ 下载 NotroSetup.exe
@@ -44,6 +44,27 @@ Discord 要收费的两件事,在应用之外解决:
---
+> 根据 2026 年 9 月 30 日更新的 [Discord 附件 FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ)及 [Nitro FAQ](https://support.discord.com/hc/en-us/articles/115000435108-What-are-Nitro-Nitro-Basic):免费 20MB、Nitro Basic 50MB、Nitro 最多 1GB。仍保留原来的 10MB 与 500MB 手动选项,实际上传额度可能因账户实验而变化。
+
+## 托盘菜单与 About(开发中的 v2.11.0)
+
+**当前公开版 v2.10.3 尚未包含此功能。** 右键点击系统时钟旁的
+Notro 托盘图标,会打开使用 Discord 风格深色配色的独立 Notro 菜单。
+可启动选择器、暂停监视、打开库、修改上传限额、快捷键、语言、
+开机启动与更新选项,并访问 **关于 Notro**。
+
+About 显示实际程序版本、发布者、Windows 支持信息、WebView2、
+MIT 许可,以及 GitHub、Releases 和 Issues 链接。本程序未签名,
+也不是 Discord 官方产品。没有 WebView2 时保留原生 Windows 菜单
+和基本 About 对话框。
+
+
+
+
+
+截图来自使用合成测试数据的真实 WebView2 QA,并非 Discord 聊天
+或普通用户机器的实际安装画面。
+
## 选择器
在任何地方按 Ctrl+Shift+E,光标旁会弹出一个深色窗口,
@@ -73,31 +94,56 @@ Discord 要收费的两件事,在应用之外解决:
- 剪贴板按钮可把当前图片直接存入 **捕获** 收藏集。设置中也可以自动保存每张新的剪贴板图片,
默认关闭。
- 窗口可以调整大小并记住尺寸(会考虑显示器缩放比例)。
-- 超出上限的项目:静态图片自动压缩,过大的 GIF 会带警告按原样发送。
+- 超出上限的项目:静态图片会自动压缩。过大的 GIF 默认保持原样并显示警告。
+ 右键点击动画 → **优化 GIF…** 可对比原始动画与候选文件的大小、分辨率,
+ 只有用户确认后才粘贴优化版,发送需手动完成。原文件和资源库不会被修改。
- 还有一个粘贴后替你按 Enter 的自动发送选项 —— 默认关闭。
+ 只在 Discord 桌面版窗口始终保持焦点时使用。其他应用需手动发送;无法检测同一窗口内的频道切换。
+ 修改剪贴板前先确认原窗口仍保持焦点。如果图片准备期间用户复制了新内容,会 **保留新内容并取消旧粘贴**。检查 Windows 是否接受模拟按键并不意味着目标应用一定成功附加文件。
+## 导入与导出媒体资源包
+
+在选择器的设置中可将已注册的表情、贴纸、GIF 及搜索名称和关键词导出为 ZIP,再导入另一台设备。
+导入前会检查文件格式、哈希和路径,并跳过重复项。
+
+不会导出私人 Captures、Discord 原始链接、剪贴板历史、监视文件夹路径或 Windows 设置。
+ZIP 并非完整设置备份。仅分享您有权分发的媒体。
+
+也可以把资源包 ZIP 直接拖放到选择器中,使用与设置中的“导入资源包”相同的安全验证。
+
+## 设置备份与恢复(JSON)
+
+选择器可将语言、快捷键、上传大小与显示尺寸备份为 JSON。
+不含图片、私人截图、剪贴板历史或 Discord 账户信息。
+监视文件夹绝对路径默认排除,只有主动勾选才包含。
+
+恢复前会显示设置与路径数量,并要求再次确认。不会删除现有
+文件夹,只能选择添加存在的路径。自动发送与自动截图保存
+在恢复后保持关闭,Windows 开机启动状态不变。
+语言和快捷键需要重启 Notro;图片仍需另用媒体 ZIP 迁移。
+
## 自动压缩
Notro 会监视剪贴板。出现新图片时,它计算 **Discord 实际会生成的 PNG 体积**,如果在上限之内
-就什么都不做。超出时按 WebP → JPEG 依次重新编码,先降质量,仍不够再降分辨率,压到约 9.5MB
+就什么都不做。超出时按 WebP → JPEG 依次重新编码,先降质量,仍不够再降分辨率,压到约 19MB
以下,并把结果**以文件形式**放回剪贴板,这样 Ctrl+V 就会作为附件上传。
> 硬盘上的原始文件绝不会被改动 —— 被替换的只有剪贴板。
-上限可以在托盘菜单里切换为 **10 / 50 / 500MB**(免费 / Nitro Basic / Nitro)。
+上限可以在托盘菜单里切换为 **10 / 20 / 50 / 500MB**(10MB 自选 / 免费 / Nitro Basic / Nitro)。
## 视频片段
复制一段过大的游戏录像时,Notro 会先询问,并告诉你结果会是什么样:
```
-52MB · 1:12 · 1080p60 → 约 9.5MB · 480p30
+52MB · 1:12 · 1080p60 → 约 19MB · 720p60
```
你可以在同一个窗口里**裁剪片段**(`开始 – 结束`)和**移除音频**,预估会随输入实时更新。
-这比任何编码参数都管用:一段 30 秒的 1080p60 录像整段处理只能退到 **1080p30**,但只保留
+这比任何编码参数都管用:一段 90 秒的 1080p60 录像整段处理只能退到 **720p60**,但只保留
你真正想要的 7 秒,就能维持 **1080p60**。
**从不打包 ffmpeg。** 第一次压缩视频时才按需下载(约 30MB,带 SHA-256 校验),如果你的
@@ -164,8 +210,8 @@ build.bat # 构建 dist\Notro.exe
| 设置 | 默认值 | 说明 |
|---|---|---|
-| `LIMIT_MB` | 10 | 上传上限 —— 也可在托盘菜单中选择 10/50/500 |
-| `SAFETY` | 0.95 | 安全余量(目标约 9.5MB) |
+| `LIMIT_MB` | 20 | 上传上限 —— 也可在托盘菜单中选择 10/20/50/500 |
+| `SAFETY` | 0.95 | 安全余量(目标约 19MB) |
| `WEBP_QUALITIES` | 90–50 | WebP 质量档位 |
| `MIN_SCALE` | 0.4 | 分辨率缩小下限 |
diff --git a/SECURITY.md b/SECURITY.md
index 76dcd2d..73e1c59 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -25,11 +25,34 @@ new clipboard images" in picker settings is **off by default**. With it off, the
only images written to your library are the ones you register explicitly.
**It simulates Ctrl+V** (and Enter, if you turn on
-auto-send) using the Windows `SendInput` API — the same mechanism as the built-in
-Windows emoji panel. It does not read your keystrokes.
-
-**It never touches the Discord client, your account, or your token.** There is no
-self-bot behaviour, no injection, and no patching of any application.
+auto-send) using the Windows `SendInput` API. Paste can work in other apps,
+but optional automatic `Enter` is restricted to the same still-focused Discord
+desktop window. Notro checks the executable's name with public Win32 APIs,
+without reading the Discord process. It cannot detect switching channels
+inside the same Discord window; auto-send is therefore off by default.
+It does not read your keystrokes.
+
+**It does not modify or inject into Discord, require a Discord account
+token or perform private client IPC.** Notro is an independent Windows OS-level
+utility, not affiliated with or endorsed by Discord. Its behavior cannot be
+guaranteed compliant with every current or future Discord policy; read the
+published architecture and keep user-directed sending manual.
+
+## Original-window focus and newer-copy protection
+
+Before changing clipboard content for a picker paste, Notro checks that the
+original target window still exists and is focused. Windows clipboard
+sequence-number checks protect newer text or file copies made while image
+preparation runs; if a newer copy is found, the paste is canceled without
+overwriting it. Windows SendInput return values are checked so rejected
+Ctrl+V injection cannot be reported as a successful paste. A successful
+SendInput return is **not proof** that Discord or another application
+accepted the attachment. Switching channels inside the same Discord
+window is not detectable. Auto-send is OFF by default.
+
+These checks use only public Win32 APIs, not Discord client hooks or REA.
+They reduce accidental copy loss but cannot guarantee uninterrupted focus
+against all external OS/clipboard interleavings.
## Network access
@@ -46,9 +69,41 @@ Notro contacts only the services documented below:
**There is no telemetry, analytics, crash reporting, or account of any kind.** No
clipboard content, file name, or usage data is ever transmitted.
-A local HTTP server binds to `127.0.0.1` on a random port while the picker is
-running, so the WebView2 control can display your library images. It serves only
-files already in your own library and is not reachable from outside your machine.
+A local HTTP server binds to `127.0.0.1` on a random port while the
+picker is running, to serve only media already in your library to the
+embedded WebView2 picker. Each start creates an unpredictable URL capability;
+requests must also use the exact loopback Host header to reduce exposure to
+unrelated local web pages and DNS-rebinding aliases. Raw paths are not
+included in asset URLs. This does not provide a sandbox against other local
+software that already has direct access to your files.
+
+## Corrupt media/backup recovery
+
+Imported ZIP packs have bounded archive, manifest, per-file and total
+decompressed sizes. Unsupported formats, malformed media metadata and
+settings backup JSON fail validation without registering media or applying
+unreviewed settings. The original media assets are not altered.
+
+If library.json or folders.json has invalid structure on startup, Notro
+moves the original bytes to a local library.json.bak or folders.json.bak
+and starts with an empty in-memory library or watched-folder list.
+**Cached image files remain on disk.** These backup JSON files may include
+private names, source URLs and local paths: never post them publicly without
+removing personal details. A full manual data recovery UI is separate work.
+
+The native WinForms window objects referenced by the Welcome and video
+dialog bridges are private fields. Only intended button and trim APIs are
+exposed to the embedded JavaScript environment, avoiding recursive native
+accessibility-object inspection.
+
+## Local preferences backup privacy
+
+An explicit settings export creates a small local JSON file with allowlisted
+preferences only; no media, private Captures, clipboard contents, tokens or
+Discord source links. Watched absolute paths are excluded by default and
+included only with consent. Import is verified and separately confirmed;
+auto-send and auto-capture are forced OFF, and startup settings unchanged.
+The image ZIP media pack is a separate workflow.
## Where your data lives
diff --git a/build.bat b/build.bat
index b026675..7b883c3 100644
--- a/build.bat
+++ b/build.bat
@@ -1,8 +1,8 @@
-@echo off
+@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo [1/3] 의존성 설치...
-pip install --upgrade pyinstaller -r requirements.txt || goto :error
+pip install -r requirements-build.txt || goto :error
echo [2/3] EXE 빌드...
python build_version_file.py || goto :error
rem --collect-all webview가 numpy(28MB)·cryptography(9.5MB)까지 끌어온다. 앱은 둘 다
diff --git a/docs/demo-owned-webview.gif b/docs/demo-owned-webview.gif
new file mode 100644
index 0000000..83a351b
Binary files /dev/null and b/docs/demo-owned-webview.gif differ
diff --git a/docs/marketing/2026-10-17-beta-user-scorecard.md b/docs/marketing/2026-10-17-beta-user-scorecard.md
new file mode 100644
index 0000000..9204f00
--- /dev/null
+++ b/docs/marketing/2026-10-17-beta-user-scorecard.md
@@ -0,0 +1,107 @@
+# Notro beta: consenting user interview / task scorecard
+
+This is a private planning template, NOT live telemetry, email marketing,
+sensitive-user collection, or permission to contact a stranger. A founder or
+trusted test coordinator must obtain opt-in consent *before* testing. Offer
+the option to stop and delete these notes. Keep responses local and avoid
+posting raw notes to public issues/Plane.
+
+## Privacy rules
+
+- Give each volunteer a neutral ID such as T01; never paste their Discord ID,
+ email, account handle, IP, chat messages, clipboard text or private paths.
+- Use owned/dummy sample media in a dedicated test chat draft. Do NOT send the
+ draft, run auto-send, or expose personal conversations during observation.
+- Make any screen recording optional, **separate from task participation**.
+ Never record unrelated windows or faces without explicit consent.
+- Explain that unsigned Windows installers may trigger warnings; do not ask
+ people to weaken OS security controls to finish a test.
+- Store screenshots/logs privately, redact filenames/paths and delete when
+ no longer needed. Report only aggregated, consented findings.
+
+## Test environment
+
+Volunteer ID: [not a name]
+Test date: [YYYY-MM-DD]
+Windows: [10/11 version]
+Discord: [desktop version if separately volunteered]
+Notro: [exact installed version/commit]
+Source: [personal invite / GitHub issue / opt-in request / other]
+Agreed to anonymous product feedback: [Yes/No]
+Agreed to capture own-window product UI: [Yes/No]
+Observed by: [role, not full name]
+
+## Task 1 — clear proposition, first successful file paste
+
+Read the Notro README/landing page once, without coaching.
+Ask the user to summarize in their own words what the emoji becomes.
+Pass only if they say it becomes an **image attachment**, not a native
+Discord inline emoji or Nitro entitlement.
+
+Expected sequence: verify owned example image; open own Notro picker;
+select search result via shortcut; preview the file in an **unsent** draft.
+Record time from the initial open to observable draft preview, excluding
+account setup. Never click Send.
+
+T1 install [PASS/FAIL/BLOCKED] Time to install [minutes]
+T1 understands attachment [PASS/FAIL] Time to first unsent paste [seconds]
+T1 hotkey works [PASS/FAIL] Clipboard content unexpectedly lost [Yes/No]
+T1 screenshot source remains unchanged [Yes/No]
+Observed hesitation/technical blocker (redacted):
+
+## Task 2 — primary user workflow (choose one)
+
+A. Personal media: add three owned images, assign name/keyword, search from
+hotkey, switch tabs, confirm repeat lookup.
+
+B. Screenshot sharing: use an owned 24MB+ generated test image, compare before/
+after dimensions/byte counts, review visible quality, open unsent attachment.
+Never invent compression ratio.
+
+C. Windows/open-source safety: inspect default OFF settings, pack ZIP privacy,
+backup JSON paths opt-in, uninstall and the unsigned installer warning.
+
+T2 type [A/B/C] Completion [PASS/FAIL/BLOCKED]
+T2 understood product limitation [PASS/FAIL]
+T2 pain points (redacted):
+
+## Follow-up after 7 days (voluntary; not behavioral tracking)
+
+Would the volunteer use Notro again? [Yes/No/Undecided]
+If yes, for which exact repeat task? [free text, no private account details]
+Uninstall friction or trust concern? [free text, redacted]
+One feature to improve before launch? [free text, redacted]
+Volunteer's request to delete the feedback notes? [Yes/No]
+
+## Minimum aggregate outcomes after 5–10 consenting testers
+
+| Metric | Numerator | Denominator | Interpretation |
+| --- | --- | --- | --- |
+| Safe install completion | TBD | TBD | Shows onboarding friction, NOT download interest |
+| Successful first unsent draft | TBD | TBD | Distinguish observed pass from unit tests |
+| First paste within 3 minutes | TBD | TBD | Workflow activation experiment |
+| Correctly explains attachments | TBD | TBD | Tests positioning clarity |
+| Clipboard preserved/no wrong send | TBD | TBD | Safety P0, zero incidents tolerated |
+| Would voluntarily reuse after 7 days | TBD | TBD | Early quality signal, not retention telemetry |
+| Reports actionable bugs | TBD | TBD | Reproduction improvement |
+
+Sample thresholds (not forecasts): 8/10 installs without bypassing security,
+7/10 correct first file draft <=3m, 6/10 correctly know it is an attachment,
+zero unintended messages, and zero clipboard data losses. Failure should
+create a private redacted Plane issue and pause public release, not be hidden
+to improve marketing conversion.
+
+Never treat GitHub download counter, star or page view as an active user
+count. No emails or analytics services are integrated in this template.
+
+## Product questions asked neutrally
+
+- What were you trying to do before seeing Notro?
+- Where did you expect the selected image to appear?
+- Which step took the most effort, and why?
+- How would you normally do it without Notro?
+- Was the unsigned Windows installer warning acceptable? Why / why not?
+- What would have to improve for you to use this every week?
+
+Avoid "Did you love this?" or leading comparisons that name a competitor
+before a tester describes their own routine.
diff --git a/docs/marketing/2026-10-17-research-validation-first-growth.md b/docs/marketing/2026-10-17-research-validation-first-growth.md
new file mode 100644
index 0000000..1e90d0b
--- /dev/null
+++ b/docs/marketing/2026-10-17-research-validation-first-growth.md
@@ -0,0 +1,310 @@
+# Notro early-user growth strategy — research and beta activation, October 17 2026
+
+**Status: NOT an authorization to post, advertise or publish a release.**
+Current public latest is v2.10.3. Candidate v2.11.0 is an unmerged Draft PR,
+and actual user Discord draft attachment/Windows UI acceptance remains NO-GO.
+The user has explicitly excluded signing. All public download guidance must
+truthfully disclose that the available installer is unsigned; do not advise
+people to bypass OS protections.
+
+## 1. The thing to sell: workflow, not an unsupported Nitro equivalent
+
+**Best initial positioning (English):**
+"A personal image, sticker and GIF picker for Discord desktop — search with
+a shortcut, paste as an attachment, and prepare bigger images to fit."
+
+**Korean:**
+"Windows에서 내 이모지·스티커·GIF를 단축키로 찾아 이미지 첨부로 붙여넣고,
+큰 스크린샷은 선택한 업로드 한도에 맞게 준비하는 도구."
+
+Disclosures directly next to the hero:
+- Images are **file attachments, never native inline Nitro emoji**.
+- Not affiliated with or approved by Discord. Compatibility can change.
+- Windows 10/11 only; installer currently unsigned and may prompt Windows.
+- Does not inject into/modify Discord or require an account token.
+- Optional auto-send/capture default OFF; avoid promising Terms compliance.
+
+Do NOT use claims such as "free Nitro", "infinite emoji", "bypass premium",
+"Discord cannot block", "guaranteed no ban", or "compress every file in
+one click". Actual output quality and upload quotas vary.
+
+## 2. Observed baseline (checked via GitHub API 2026-10-17)
+
+Repository writingdeveloper/Notro:
+- Stars: 0; forks: 0.
+- Latest public v2.10.3 installer asset count: 35 downloads; its SHA file:
+ 14 downloads. These are **asset download counters**, not unique installers
+ or successfully installed users.
+- GitHub traffic API: 13 repo views, 8 unique visitors in the trailing
+ reported two-week window. Not a marketing conversion/active use measure.
+- Open public issues reported by issue list: none (PRs not included).
+- writingdeveloper.blog/projects/notro live page still mentions "10MB" and
+ "v2.0"; has not been updated to match the current published release.
+- Portfolio Draft PR #36 and separate security Draft PR #33 both have failing
+ GitHub checks as of inspection, so do not bypass the npm vulnerability gate.
+
+**Interpretation:** low observable awareness, but NO product-market fit
+evidence either way. Buyer/user research and first-task completion must
+precede paid traffic or a wide community post.
+
+## 3. Competitive substitution: comparison on jobs-to-be-done
+
+| Alternative | Verified strength | Notro's credible distinction | Why a user might still prefer them |
+| --- | --- | --- | --- |
+| Discord native emoji/GIF chooser | Built into the chat app; keyboard access to normal emojis/stickers/GIF | Owned portable local image collection + external Windows hotkey | Native rendering, zero extra software; Notro emojis are attachments |
+| Microsoft PowerToys Advanced Paste | Clipboard formats, paste as PNG file, optional audio/video transcodes | Dedicated searchable persistent personal media library | Broader Microsoft-supported toolbox |
+| ShareX | Strong screen/video capture, automation and file sharing | Hotkey to favorite media collections, not a capture-suite substitute | Mature screenshot capture and editing tool |
+| Vencord FakeNitro | In-client fake-emoji/sticker functions and Nitro-like visual behavior | Independent OS-level app, **no Discord patches or plugins** | More Discord-native visual experience, different risk trade-offs |
+| EmojiPick (MrLouix) | Global emoji hotkey and fuzzy text lookup; optional local LLM matching | Custom media assets, packs, GIFs and Discord-sized output | Context-aware standard emoji suggestions |
+
+Competitor references for factual comparison:
+- https://learn.microsoft.com/windows/powertoys/advanced-paste
+- https://github.com/ShareX/ShareX
+- https://vencord.dev/plugins/FakeNitro
+- https://github.com/MrLouix/emoji-picker
+- https://www.reddit.com/r/discordapp/comments/1owbrfi/
+- https://www.reddit.com/r/discordapp/comments/1vtamuj/
+
+Never claim these tools all have the same functionality, or that all
+competitor workflows are equally safe/policy-compliant. This matrix compares
+observable documented functions only; no adoption figures were available.
+
+## 4. Primary audience / priority and job test
+
+### Segment A: Emoji and GIF-heavy Windows Discord users (priority 1)
+
+Task: install, import three personally owned images, find one with
+Ctrl+Shift+E, show an unsent image attachment draft, and find it again
+without a mouse. Measure first task completion, confusion about attachment
+versus native emoji, hotkey conflicts and return intent.
+
+Rationale: repeatable daily workflow and searchable personal library,
+not just a one-time file compression. Discord users have reported friction
+with emoji UI and keyboard access in community posts; these are qualitative
+signals, not market sizing:
+https://www.reddit.com/r/discordapp/comments/1owbrfi/
+https://www.reddit.com/r/discordapp/comments/1vtamuj/
+
+### Segment B: Gamers sharing oversized images (priority 2)
+
+Task: with rights-cleared 24MiB+ original screenshot, compare original and
+Notro output dimensions/size, paste it into an unsent private draft, and
+inspect perceptual quality. Do not fabricate or generalize a sample
+compression ratio. Sample video compression only if actual executable
+preview and encoded source footage have been visually reviewed.
+
+### Segment C: Windows open-source / privacy-conscious users (priority 3)
+
+Task: source audit, no account token, default-safe settings, JSON backup,
+portable pack, transparent update endpoints, unsigned install friction.
+Use this group to check trust and support burden, not to imply formal
+policy approval.
+
+**Hypothesis to test:** Segment A returns to the searchable library more
+often than one-off Segment B. Must be measured; not a factual conclusion.
+
+## 5. Distribution: permission-aware ordering (no posts yet)
+
+### Tier 0: Owned surfaces and consented feedback
+
+Improve Notro README and release notes to clearly show published vs draft
+features; keep the rights-cleared native WebView2 demo. Finish portfolio
+dependency security audit before deploying the corrected landing page.
+Use personal/known opt-in beta testers; no unsolicited private DMs.
+
+### Tier 1: Windows/open-source maker communities after beta gate
+
+GitHub Topics, own development blog, an authentic technical changelog,
+small Windows utility communities and dev forums where project showcases
+are explicitly permitted. Check current moderator rules before posting.
+
+Reddit r/discordapp moderator messages distinguish casual personal
+bot/project sharing from aggressive promotion. Repeated advertising can be
+removed. Do not use it as the first/high-volume outbound channel:
+https://www.reddit.com/r/discordapp/comments/1v7segl/
+
+### Tier 2: Product Hunt after repeatable value is validated
+
+The official guide prioritizes working production products, crisp benefit,
+authentic demo and an engaged maker answering feedback. Suggested potential
+topics include Windows, Open Source and Productivity (confirm availability).
+Actual Product Hunt tagline must be <=60 chars, short description <=260 chars.
+Do NOT ask for upvotes or engineer vote rings:
+https://help.producthunt.com/en/articles/17350772-launch-guide
+
+### Tier 3: Hacker News only if the founder writes the post personally
+
+Official HN rules say no generated text in HN posts, no generated/AI-edited
+comments, and no automated posting; HN is not primarily an ad channel.
+This repo may contain internal FAQ/research *notes*, but **do not post
+assistant-generated text** to HN. When technically mature, the maintainer
+can write their own narrative around interesting Windows clipboard
+race conditions, native WebView2, privacy and verifiable release pipeline.
+https://news.ycombinator.com/newsguidelines.html
+
+## 6. Four gated experiments (examples, not forecasts)
+
+**Experiment 0: safety/availability** (P0, before recruitment)
+- Windows 10/11 real GUI installation, tray, default-off controls, and an
+ actual Discord non-sending attachment draft pass.
+- Portfolio has accurate text and a green dependency security audit.
+- A version-matched unsigned candidate is accessible through a human-
+ approved download with checksum and an honest Windows warning.
+- If any step fails: hold outreach, fix Plane P0 first.
+
+**Experiment 1: 5-10 consenting Windows users (0 paid ad spend)**
+- 4-5 segment A, 2-3 segment B, 1-2 segment C.
+- Each tester receives the same clean-room tasks and a test profile where
+ feasible. Consent is explicit and reversible.
+- Targets for *deciding what to improve*, not predicted results:
+ >=8/10 installed without unsafe bypass, >=7/10 first successful manual
+ draft file paste in 3 minutes, >=6/10 accurately understand attachment
+ versus native emoji, no unintended send/data loss.
+- Observe where they hesitate; record minutes and notes locally, not
+ keystrokes/clipboard/chat messages.
+
+**Experiment 2: A/B first claim, not two different apps**
+- Group A short hero: "Your image library, one hotkey away."
+- Group B short hero: "Prepare and paste Discord screenshots in seconds."
+- Same demo, unsigned warning, download CTA and privacy notice.
+- Only after comparable traffic exists can clicks/qualitative reaction
+ justify choosing one. Do not derive statistical conclusions from <20
+ visitors or count install assets as unique customers.
+
+**Experiment 3: release content once beta pass**
+- 10-20 second genuine native picker UI (already captured with owned art).
+- Separate 10-20 second *actually tested* screenshot optimize/draft demo,
+ then gameplay clip or portable media pack if tested.
+- English landing first, Korean localized matching function and version.
+- Manually review every public claim with real artifact/QA evidence.
+
+## 7. Ethical, privacy-preserving measurement
+
+Track only aggregate site sessions, CTA clicks and GitHub asset download
+counters; baseline correctly says the latter are not installs.
+For volunteers collect a simple **opt-in, local** scorecard:
+OS/Discord version, case passed, time to first manual draft paste,
+hotkey confidence, clarity about attachments and whether they would use
+it again in 7 days. No account identifiers, copied private text, filename
+lists, Discord IDs/messages or always-on clipboard telemetry.
+
+Suggested cohort reporting: invited / consented / installed / first paste /
+understood / voluntarily reused, each numerator and denominator visible.
+Do not store sensitive data in public Plane issue descriptions. If fewer
+than 5 testers respond, label evidence exploratory.
+
+## 8. Proposed publication and support guardrails
+
+- Candidate PR remains Draft until P0 human QA and dependent landing audit.
+- Signing explicitly excluded; describe unsigned trust and actual hashes,
+ never instruct disabling Defender or SmartScreen.
+- Notro is independent, not endorsed by Discord, and is **not** a way to
+ bypass paid entitlements or send native emojis without Nitro.
+- Discord prohibits automating ordinary user accounts as self-bots outside
+ supported bot APIs:
+ https://support.discord.com/hc/en-us/articles/115002192352-Automated-User-Accounts-Self-Bots
+ Therefore retain user-directed paste, auto-send off by default and review
+ account-policy risk before any public messaging.
+- No guerrilla spam, fake reviews, fabricated screenshots, Reddit mass posts,
+ platform vote solicitation or AI-written HN submissions/comments.
+- No release, login, ad buy or community post was made in this research stage.
+
+## 9. Next development handoff
+
+- Implemented AND VERIFIED on GitHub-hosted Windows runner 39075263021
+ (source 77689d6): clipboard focus-before-mutate, newer-copy detection,
+ Win32 SendInput acknowledgment and actual Ctrl+V delivery to a separately
+ owned Tk foreground test window. No Discord attachment was made; that
+ remains distinct P0 NOTRO-5.
+- Still P0: real Discord unsent attachment on a dedicated safe test surface,
+ real Windows 10/11 first run, audited bilingual portfolio landing, then
+ real working v2.11.0 release with honest unsigned disclosure.
+- Already Done: actual native WebView2 picker demo with original icons and
+ hosted-runner CF_HDROP/Unicode clipboard primitives.
+
+
+## Product Hunt effectiveness — updated October 17, 2026
+
+Decision: DEFER Product Hunt as a primary acquisition channel for Notro.
+It is not necessarily dead, but upvotes and launch-day traffic are unreliable
+proxies for installed users or repeat use. Notro's public audience is small
+and candidate v2.11.0 is not yet publicly downloadable.
+
+### Recent founder experiences — not representative statistics
+
+| Self-report / date | Observed result | Interpretation |
+| --- | --- | --- |
+| ProductHunters, Oct 9 2026 (Ranks Hawk) | #34, 46 votes, 17 comments, 20 site visits, no sales | Votes did not equal customer acquisition |
+| r/SaaS, July 2 2026 | Unsolicited sales-agency emails, zero customers claimed | Spam/vendor-solicitation cost |
+| r/digital_marketing, Jan 14 2026 | #1 day/week, ~4,500 visitors, 370 signups, ~10 teams actually using it at 2 weeks | Exceptional ranking CAN drive visits, not equivalent to durable adoption |
+| r/SaaS, Oct 14 2026 | Free dev tool Top 5, thousands of visitors; later ~10 active users, 300 downloads | Free launch exposure may decay without repeat use |
+
+These are individuals' potentially biased/unverified experiences, not a
+representative sample, Notro forecasts or causal evidence:
+- https://www.reddit.com/r/ProductHunters/comments/1x1g9fa/
+- https://www.reddit.com/r/SaaS/comments/1ulbwia/
+- https://www.reddit.com/r/digital_marketing/comments/1qcpxhp/
+- https://www.reddit.com/r/SaaS/comments/1x5lwbe/
+
+### Platform rules as of October 2026
+
+Product Hunt started engagement-based POINTS rather than raw upvotes in
+October 2025; authentic activity and comments matter, and ranking factors
+are not fully disclosed:
+https://help.producthunt.com/en/articles/10275873-what-are-points
+
+Its March 2026 Featuring Guidelines emphasize live, useful, novel and
+well-crafted products. Homepage featuring is editorial, never guaranteed:
+https://help.producthunt.com/en/articles/9883485-product-hunt-featuring-guidelines
+
+Official launch guide says quality preparation starts weeks before launch
+and real audience engagement matters:
+https://help.producthunt.com/en/articles/17350772-launch-guide
+
+The official launch page says listing is free, maker self-hunting is
+allowed, and founders must NOT directly ask for upvotes or coordinate
+vote rings:
+https://www.producthunt.com/launch
+
+### Product Hunt own Q2 2026 report: platform is active, not dead
+
+Product Hunt's official Q2 2026 State of Tech Discovery report counts
+2,345 launches versus 1,508 in Q2 2025 (about 55.5% growth). Open Source
+launches increased 143% year-on-year; that category reported 243 launches
+and mean 192 upvotes, compared with a mean 190 across launches overall.
+AI was the largest category (1,111 launches, mean 252 upvotes), but Open
+Source remains active and heavily overlaps Developer Tools.
+Source: https://www.producthunt.com/p/general/product-hunt-s-state-of-tech-discovery-q2-2026
+
+This is Product Hunt's own publisher data and measures launches/engagement,
+not verified outside visitor traffic, installs, paid customers or user
+retention. It also reveals increased supply competing for attention; a
+higher number of launches is NOT proof of higher conversion or better
+exposure for a niche Windows-only Discord attachment utility.
+Avoid extrapolating the report into Notro's expected signup/install count.
+
+### Notro-specific channel decision and time budget
+
+1. Now: zero ad spend and zero PH preparation; complete real installed
+ Windows GUI/unsent Discord draft, audited bilingual portfolio landing,
+ and consented 5-10 person beta. Fix failures as Plane P0s first.
+2. After P0s: publish the actual version-matched unsigned product with
+ checksum/known limitations and rights-cleared demos. Reach Windows
+ and Discord utility users through permitted, authentic discussions and
+ task-oriented own-site documentation.
+3. If at least 5 volunteers complete the core workflow and at least
+ 2 independently express intent to reuse it, allocate at most 2-4
+ developer hours for an optional FREE Product Hunt listing; otherwise
+ skip until subsequent major updates. These are exploratory thresholds,
+ not statistical proof.
+4. Measure source-attributed visits if voluntary privacy-safe metrics
+ become available, verified installed beta users, first unsent draft
+ completion, understanding that emoji are attachments, and voluntary
+ week-1 return. Stars, PH points and release downloads are not users.
+5. If PH yields mostly agency solicitations with no verified installs
+ or actionable feedback, do not invest more. Prioritize FAQ/how-to
+ videos, long-tail Windows/Discord task SEO, own GitHub and approved
+ community conversations instead.
+
+Conclusion: Product Hunt is a later optional small discovery experiment,
+not the main Notro product-market validation or launch strategy.
diff --git a/docs/marketing/landing-copy-en-ko.md b/docs/marketing/landing-copy-en-ko.md
new file mode 100644
index 0000000..c51e863
--- /dev/null
+++ b/docs/marketing/landing-copy-en-ko.md
@@ -0,0 +1,26 @@
+# WritingDeveloper Notro landing metadata and text
+
+Live page: https://writingdeveloper.blog/projects/notro
+Status: READY TO APPLY in the separate website repository, NOT YET DEPLOYED.
+
+## EN
+
+SEO title: Notro — Discord Emoji Picker & Clipboard Media Optimizer for Windows
+Meta description: Notro is a free, open-source Windows companion for Discord: personal emoji/GIF/sticker packs, hotkey search, and media optimization for upload limits. No client modification or login. Emoji are attachments.
+H1: Your personal emoji library, ready to paste.
+Support: Find and paste emoji, stickers and GIFs from your own collection. Prepare large screenshots and clips to fit the upload size you choose. Windows 10/11.
+Primary CTA: Download for Windows → https://github.com/writingdeveloper/Notro/releases/latest
+Secondary CTA: View source & security → https://github.com/writingdeveloper/Notro
+Truthful notices: "Emoji arrive as image attachments, not native inline emoji." "Not affiliated with Discord." "Unsigned installer: Windows SmartScreen may warn; see checksums and build instructions."
+
+## KO
+
+SEO title: Notro — 디스코드 이모지 선택기 · 클립보드 미디어 최적화
+Meta description: Windows용 무료 오픈소스 Notro. 개인 이모지·GIF·스티커 팩을 관리해 붙여넣고, 큰 이미지와 영상을 전송 용량에 맞게 준비합니다. Discord 클라이언트 수정·로그인 불필요. 이모지는 첨부파일로 전송됩니다.
+H1: 내 이모지, 단축키 하나로 붙여넣기
+Support: 개인 라이브러리에서 이모지·스티커·GIF를 찾아 바로 붙여넣고 큰 미디어를 용량에 맞게 준비하세요.
+Notices: Discord의 공식 제품이 아닙니다. 현재 설치 프로그램은 미서명 상태이며 Windows 경고가 표시될 수 있습니다.
+
+## Verification after deployment
+
+Server-rendered title/description/OG must no longer say 10MB (official Discord FAQ updated 2026-09-30: free 20MB, Nitro Basic 50MB, Nitro up to 1GB). Add a genuine current picker screenshot and accurate 1200x630 social preview. Verify CTA resolves to the reviewed release. The existing site described v2.0 even though Notro main was v2.10.3.
diff --git a/docs/marketing/launch-kit-2026-10.md b/docs/marketing/launch-kit-2026-10.md
new file mode 100644
index 0000000..5d13b89
--- /dev/null
+++ b/docs/marketing/launch-kit-2026-10.md
@@ -0,0 +1,84 @@
+# Notro launch kit (2026-10)
+
+## What it is
+
+Independent Windows companion for Discord desktop: organize personal emoji/sticker/GIF media, paste with a hotkey, and prepare oversized screenshots and video. Nothing injects into Discord and no account token is needed. Images are attached as files, **not native Nitro emoji**. Not affiliated with Discord. Windows-only; current installer is unsigned.
+
+EN headline: **Your emoji library. Your game clips. One Windows tray app.**
+KO headline: **내 이모지와 게임 클립을, Windows 트레이에서 바로.**
+
+EN supporting: "Paste your emoji as attachments, organize your GIFs, and prepare oversized media for Discord without modifying the client."
+KO supporting: "개인 이모지·스티커·GIF를 라이브러리로 관리해 첨부파일로 붙여넣고, 큰 미디어를 업로드 한도에 맞게 준비합니다."
+
+## Core audiences
+
+1. Discord gamers with oversized screenshots and gameplay captures.
+2. Users maintaining an owned/reusable personal emoji, sticker and GIF library.
+3. Windows/open-source users preferring a standalone utility to client modifications.
+
+## Three genuine 10–20s demo concepts
+
+A. Big screenshot: use a real 24MB screenshot, show copy → size-aware compression → paste into an unpublished test draft. No fabricated comparisons.
+B. Emoji hotkey: Ctrl+Shift+E → search → image attachment preview. On-screen subtitle: "Attachment, not native inline emoji."
+C. Personal pack: user-owned assets → export ZIP → import into a clean test profile → find the saved sticker. Never redistribute copyrighted assets without rights.
+
+Record the actual UI; do not represent the picker browser mock as a finished Discord integration demo.
+
+## Manual promotion ladder
+
+1. Finish real installer and Discord-draft E2E QA; if release is approved, publish a checksum-verified unsigned installer with a clear Windows warning.
+2. Invite 10–20 volunteers, measure first-use task completion with consent, gather concrete UX bugs.
+3. Publish concise EN/KO demos to relevant video channels, with a direct verified download link.
+4. Contact moderators or check each community's promotion policy before Reddit/Discord posts. No mass spam, bought reactions or misleading claims.
+5. Reuse and update existing docs/show-hn-draft.md for Show HN; answer with limitations and technical specifics, not invented numbers.
+6. Product Hunt only after early-user evidence suggests value beyond developer communities.
+
+## Privacy-respecting measurement
+
+Landing-page visit and download CTA clicks, GitHub release counts, opt-in human testers' first-paste time and self-reported follow-up use. No application clipboard telemetry, account data or file-name analytics.
+
+Illustrative 30-day experiment goals (not forecasts): 20 testers, 80% first-paste completion, median <=3 minutes to first success, >=10 actionable user reports.
+
+The website https://writingdeveloper.blog/projects/notro still has outdated 10MB metadata. Replace in the separate website repository before broad promotion. Do not post publicly before real release QA.
+
+
+## October 17, 2026 launch holds
+
+- Published v2.10.3 differs from unreleased Draft PR v2.11.0. The releases/latest
+ CTA must not advertise features that are not downloadable.
+- Live writingdeveloper.blog/projects/notro still claims free 10MB, v2.0 and
+ blanket Terms-safety. Portfolio Draft PR #36 fails npm security audit.
+- Native synthetic QA is not real Windows user install or Discord paste proof.
+- Docs demo.gif and picker.png have identifiable third-party character images.
+ Verify marketing rights or replace with rights-cleared demonstration assets.
+- Do not promise Discord endorsement, Terms compliance or that Discord cannot
+ break/block future functionality. Auto-send remains off by default.
+- Hacker News bans AI-generated submission text and automated posting.
+ Reddit promotions must follow community rules, without unsolicited mass posts.
+- User has explicitly excluded code signing. Disclose the unsigned status;
+ do not request certificates or enable signing infrastructure.
+- Complete limited opt-in beta QA and accurate releases/landing first, then
+ human-managed public marketing with authentic demos.
+
+
+## October 17 follow-up: owned native picker demo produced (NOT launch approval)
+
+- A **15.9-second, nine-scene capture from the actual pywebview/Windows
+ CoreWebView2 picker** was generated with original geometric Pillow shapes
+ (18 emoji, 4 stickers, 3 small GIFs). Outputs:
+ docs/demo-owned-webview.gif and docs/picker-owned-webview.png.
+- It uses CoreWebView2's own CapturePreview on the fixture window only,
+ not a desktop/screen grab. The QA process blocks reads/writes of real
+ Notro HKCU settings and validates that auto-send/capture are OFF.
+ No Discord, account, real chat, global clipboard, or third-party artwork
+ is present. Exact source and limitations are documented in
+ docs/marketing/owned-native-demo-2026-10-17.md.
+- All five README files now embed only the newly rights-cleared native
+ picker demo. Old docs/demo.gif and docs/picker.png are historical art,
+ NOT licensed public marketing materials. Do not use them in promotions.
+- One UI-only demo is therefore ready for human review; it is **not** the
+ separate big-screenshot compression, game-clip or Discord attachment demo.
+ Real-world file attachment evidence and approved Windows EXE first-run
+ footage must still be collected with explicit consent.
+- No announcement/release or community post was made. Existing portfolio
+ npm audit blocker is still a public launch hold.
diff --git a/docs/marketing/owned-native-demo-2026-10-17.md b/docs/marketing/owned-native-demo-2026-10-17.md
new file mode 100644
index 0000000..116a740
--- /dev/null
+++ b/docs/marketing/owned-native-demo-2026-10-17.md
@@ -0,0 +1,65 @@
+# Owned visual demo: native Windows WebView2 (2026-10-17)
+
+**Evidence type: actual Notro picker UI rendered by the pywebview Windows
+Edge WebView2 backend in an isolated temporary test profile.**
+This is not a screenshot of Discord, not an uploaded attachment, not a
+demo of the packaged/installed EXE and not a test of clipboard or account
+integration. No actual Notro user library, personal folders or Discord data
+are read or captured.
+
+## Reproducible assets
+
+- Animation: docs/demo-owned-webview.gif — 9 UI scenes, 15.9 seconds,
+ about 390 KB, 850x607 pixel output at the capture monitor's DPI.
+- Still: docs/picker-owned-webview.png — picker gallery screenshot.
+- Source: scripts/record_owned_native_demo.py
+- Artwork: 18 emoji, 4 stickers and 3 animated GIFs created by the script
+ with Pillow geometric primitives (suns, moons, comets, hearts, rings).
+ No third-party anime characters, stock art, Discord or other proprietary
+ media are imported. They are newly produced by this project's code.
+- Privacy: uses CoreWebView2.CapturePreviewAsync **on our own WebView2
+ control**, not desktop screenshots, screen capture or another process's UI.
+- Settings: in the test subprocess all application setting reads/writes are
+ isolated, never accessing Windows HKCU Notro preferences. It asserts that
+ auto-send and auto-capture checkboxes are unchecked.
+- Isolation: uses a temporary media library and localhost asset server.
+ The window is closed, the server stopped and temporary originals removed
+ after GIF/PNG creation. No global clipboard or keyboard SendInput.
+
+## Captured scenes
+
+1. Emoji gallery with Favorites/Recent items
+2. Text input search for sun through the real JS event handler
+3. Keyboard arrow-navigation through the real picker JS handler
+4. Sticker tab, original icons only
+5. GIF tab, original generated animations
+6. Same GIFs with a later animation frame
+7. Settings (auto-send and auto-capture visibly disabled)
+8. Pack / JSON preferences backup controls, directory paths excluded
+9. Return to gallery
+
+Regenerate on an authorized Windows QA PC with Python, Pillow, pywebview,
+Microsoft WebView2 installed:
+
+ python scripts/record_owned_native_demo.py
+
+The default output directory is docs, override with --output-dir.
+
+## Public and historic media distinction
+
+All five current README files now link ONLY the owned native WebView2 demo.
+The older docs/demo.gif and docs/picker.png still contain recognizable
+third-party characters. They are retained for historical development
+review and must not be used in public marketing without redistribution
+rights confirmed by the maintainer. They are not automatically
+made distributable by being in this project.
+
+The new demo can accurately illustrate picker UI, search, keyboard handler,
+tabs and backup controls. It does not prove real Discord attachment delivery,
+installed app launch, actual subscription limits or audio/video compression
+in the Discord chat interface. Those require separate user-approved
+unpublished-draft demonstrations and real Windows install evidence.
+
+This is independent of Discord and makes no policy compliance or
+endorsement claim. Creating these files is not authorization to post
+on HN/Reddit/Discord or to publish a new public Notro release.
diff --git a/docs/picker-owned-webview.png b/docs/picker-owned-webview.png
new file mode 100644
index 0000000..0818adf
Binary files /dev/null and b/docs/picker-owned-webview.png differ
diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md
new file mode 100644
index 0000000..21ba331
--- /dev/null
+++ b/docs/qa/2026-10-13-notro-readiness.md
@@ -0,0 +1,258 @@
+# Notro readiness QA checkpoint — 2026-10-13
+
+Baseline: main 537c8b0, published v2.10.3. Feature work is isolated in the feat/notro-rea-product-marketing-20261013 worktree, without changing the running Discord installation or user library.
+
+## Verified
+
+- Official Discord upload FAQ read: free 20MB / Nitro Basic 50MB / Nitro 500MB. Variable trial limits may apply. https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ
+- Safe 20MB free default, old manually selected 10MB preserved, actual ceiling separated from 95% encode target. Regressions tested.
+- Watched folder discovery includes JPG/JPEG; unit reproduction fixed.
+- Opt-in portable ZIP media packs with private Capture exclusion, no source URLs, content hashes, zip entry/path/size checks and duplicate merging.
+- First-run welcome has a "Try picker" action; auto-send remains off by default.
+- Local media HTTP responses streamed via bounded 64 KiB chunks.
+- Python: 370 tests passed locally; Ruff 0.15.21 passed (0 issues). GitHub CI initially failed after floating Ruff resolved to 0.16.10 with expanded default rules; pinned the validated version and requires a second remote CI pass.
+- Browser mock smoke: Chrome headless screenshot of standalone picker, 6 tiles/3 tabs, import/export Settings buttons visible, Escape closes settings, search "miku" returned 2 tiles, 0 page JS errors. THIS IS NOT DISCORD E2E.
+- Synthetic library benchmark, one sample on @4080 (cold/warm/search in milliseconds):
+ - 100 files: 4.02 / 0.70 / 0.87
+ - 1,000 files: 33.70 / 1.46 / 2.36
+ - 5,000 files: 161.56 / 6.59 / 12.64
+ Browser DOM performance and actual GIF memory still need testing.
+- REA static audit: see docs/research/2026-10-13-rea-discord-audit.md.
+
+## Still open
+
+- Real Discord E2E image/video delivery, DPI, Korean filenames, GIF playback, chat focus and tray installation QA.
+- Code-signing / SmartScreen trust: not solved; do not claim signed.
+- Lossy GIF optimization needs opt-in quality preview and cancellation.
+- Browser picker with 5,000 mixed-size real assets, WebView2 process memory and scrolling performance.
+- Standalone media pack ZIP is NOT a full registry/settings/watched-folder backup.
+- Production website metadata still needs changes in a separate deployment repository.
+- No public Show HN/Reddit/Discord marketing posts performed.
+
+## Release gate
+
+1. Run Python test suite, ruff, release version contract on Windows 3.12 CI.
+2. Test native WebView2 file dialogs, 5 languages and keyboard-only first run.
+3. In an explicitly private test draft with auto-send disabled, paste 19.5MB (unchanged), 24MB (compressed), JPG, GIF and video; confirm disk originals unchanged.
+4. Bump package version, build a fresh installer (never republish the old v2.10.3 tag), verify SHA256 and signing.
+5. Update Plane from QA to Done only with E2E evidence. Publish only after E2E and user-approved release trust review.
+
+Follow-up draft PRs: Notro https://github.com/writingdeveloper/Notro/pull/1 ; portfolio SEO metadata https://github.com/writingdeveloper/portfolio/pull/36 (neither merged or deployed).
+
+## Release candidate prepared
+
+Source version has been advanced to **2.11.0**, with an unreleased CHANGELOG entry. No v2.11.0 tag or installer release was created. The feature branch candidate 5b14eba passed GitHub Actions CI and the manual Release workflow_dispatch build: EXE, Inno Setup installer and SHA256 (https://github.com/writingdeveloper/Notro/actions/runs/38486869698). Publishing was correctly skipped. GUI installation and code signing have not been validated.
+
+Portfolio PR #36 passes type/lint/build/tests but the dependency audit fails with 11 findings (10 high, 1 critical); see https://github.com/writingdeveloper/portfolio/actions/runs/38486167720. Do not bypass audit or deploy; coordination with existing portfolio security work is required.
+
+
+## Follow-up: OS-only compatibility and isolated native Windows QA
+
+- REA is an offline developer investigation aid only, not bundled in or
+ launched by Notro. Added architecture regression tests against runtime
+ imports of REA/Discord private modules. No Discord process memory, IPC,
+ message history or tokens were accessed.
+- Win32 process-window identity enumeration detected one visible Discord
+ desktop top-level window, matching the Discord.exe executable already
+ running. Read-only verification; no keys were sent to that window.
+- Optional auto-send is now guarded by the saved HWND and a public
+ QueryFullProcessImageNameW process-name check. The target must still be
+ focused before pasting and immediately before Enter. Paste still works in
+ non-Discord Windows applications but they never receive automated Enter.
+ Channel switches inside the same Discord window cannot be detected.
+ Auto-send remains off by default.
+- Actual Edge WebView2 smoke: a separate fixture window using two temporary
+ PNG/JPEG items displayed both thumbnails, three tabs, pack import/export
+ buttons, correct search results, KO/EN/JA/ZH/ES UI language updates, zero
+ errors, closed normally. The fixture does not register global hotkeys, alter
+ clipboard/registry, or invoke Discord.
+- Asset URL server now uses a per-run bearer capability in the local URL
+ path and a strict loopback Host check; regression tests cover
+ unauthenticated asset URLs and spoofed Host. Runtime thumbnail loading
+ verified in native WebView2.
+- Imported pack manifests are now read through a bounded decompression path.
+- Unverified: actual Discord file-attachment draft send, simultaneous
+ channel changes, WebView2 native file-open/save dialogs with the user's
+ profile, full installer installation or SmartScreen. Do not call
+ these verified or publish yet.
+
+
+## Verified continuation: standalone file-sharing UX
+
+- A single ZIP file dropped into the picker now routes through the same Python
+ validation method as the Settings Import button. Mixed multiple files retain
+ the existing media-add flow.
+- The native WebView2 fixture exported its two owned media assets, then
+ simulated a ZIP drop through the real Python JS bridge. The importer safely
+ skipped both duplicates, updated the on-screen status hint, and no JavaScript
+ or WebView errors occurred. Five localized search labels and both actual
+ thumbnails remained functional.
+- Current local regression suite: **393 passed**, Ruff 0.15.21 clean,
+ Python compileall passed and source version remains **2.11.0 unreleased**.
+- This test does **not** simulate a real user's native Windows file-open
+ dialog or Discord account; those acceptance criteria remain open in Plane.
+
+
+## Plane continuation and non-regression
+
+- Project: soursea / NOTRO, 19 tracked work items.
+- New: NOTRO-17 OS-only paste/auto-send guard (QA);
+ NOTRO-18 native isolated WebView2 five-language test (Done);
+ NOTRO-19 local asset capability/Host protection (QA).
+- The idempotent plane_sync script now PATCHes only descriptions of existing
+ items. It retains external/manual status, priority and ownership decisions
+ rather than resetting a completed item when a new session resumes. New
+ items still receive their initial planned state. Unit tests cover both paths.
+- Latest local suite: 395 Python tests passed; Ruff lint of notro_app,
+ scripts and tests passed. The published release remains v2.10.3 and the
+ candidate is still v2.11.0 (unpublished).
+
+
+## Opt-in GIF optimization (unreleased candidate, separate QA)
+
+- Added bounded color-palette-first GIF optimizer with fallback downscaling.
+ Input caps: 60 MiB source, 180 frames, 25 million aggregate decoded pixels,
+ max 4096px dimension. Candidate is a separate temp GIF with unchanged
+ loop and approximately matched frame timing.
+- Preview only appears if the prepared GIF exceeds the **actual** upload cap;
+ the compressed output aims below the 95%-safety target. If the image already
+ fits, or safe optimization cannot meet the target, the original remains
+ unchanged and the user sees a plain-language explanation.
+- The picker right-click action opens a 2-column animated before/after window
+ with exact size, resolution and frame-count comparison, requiring explicit
+ approval. The optimized preview is never silently transmitted.
+- No Discord modifications, REA runtime code, token access or automatic Enter.
+ The accepted candidate uses the existing OS clipboard/file paste route
+ with auto-send disabled. GIF preview URLs are randomized localhost temporary
+ registrations, removed on cancel or picker shutdown.
+- Real @4080 native WebView2 QA passed: animations both loaded, cancel removed
+ candidate, synthetic user acceptance went to an **inert mocked controller**,
+ source SHA-256 unchanged. No live clipboard, Discord message or library
+ data was touched. Real Discord draft attachment behavior is still unverified.
+
+
+## Latest GIF UI completion gate
+
+- User-visible right-click menu supports animated media, including watched-folder
+ GIF items; no Discord internal APIs or client patching are involved.
+- As a regression, the original/optimized comparison uses the *same prepared
+ source file* when paste-size normalization is configured. Avoids misleading
+ before/after byte counts. Actual upload ceiling triggers optimization;
+ the lower 95% safety target only applies to encoded candidates.
+- UI states (loading, preview, size/frames, cancel, explicit manual paste),
+ five-language text parity, local ephemeral URL cleanup, stale source/limit
+ checks, and temporary-file lifecycle are tested.
+- Native WebView2 fixture: three synthetic assets, bilingual/multilingual
+ picker UI, source/candidate GIF loaded, explicit cancel removed preview,
+ explicit accept routed through a fake no-clipboard controller. **No Discord
+ message was sent and the real system clipboard was not changed.**
+- QA remains open for a human visual review of actual GIF quality and real
+ Discord draft attachment in a non-sending test context before releasing.
+
+
+## Temporary unsigned QA installer
+
+Manual workflow_dispatch now uploads NotroSetup.exe and its SHA-256 as a
+3-day NOTRO-UNSIGNED-QA artifact. This supports actual installer testing
+without a release or tag. Artifact access follows GitHub repository
+permissions, and this build must not be marketed as trusted or signed.
+Real installer, SmartScreen and Discord E2E tests remain open.
+
+
+## Verified Windows QA installer build (manual run 38493606960)
+
+- GitHub Actions: https://github.com/writingdeveloper/Notro/actions/runs/38493606960
+- Build ref: 668b117 (branch feat/notro-rea-product-marketing-20261013).
+- Windows tests, PyInstaller EXE, Inno Setup installer, SHA-256 generation and
+ actions/upload-artifact@v7 completed successfully. Publishing was skipped:
+ no GitHub tag or public v2.11.0 release.
+- UNSIGNED QA installer downloaded to a separate local temporary folder on
+ @4080 without executing or installing it. The checksum file matched actual
+ NotroSetup.exe SHA-256:
+ 48244c04e5ca765fe8683565a1d3a8474ca98018e04ddb511b4dc7cf25e9d0ef
+- File size: 17,090,810 bytes. Windows VersionInfo ProductName=Notro;
+ FileVersion=2.11.0; ProductVersion=2.11.0.
+- Windows Get-AuthenticodeSignature returned NotSigned.
+ SHA-256 consistency proves integrity relative to the workflow checksum,
+ not code-signing identity or software safety.
+- 3-day artifact is for QA; do not claim code signing, SmartScreen reputation,
+ real installation success, Discord chat end-to-end, or release readiness.
+
+
+## Follow-up real Win32 dialog and native gallery QA (2026-10-13)
+
+- To reproduce on @4080 with pywebview, Pillow, pywinauto, pywin32:
+ python scripts/qa_native_file_dialogs.py
+- The harness uses an isolated Edge WebView2 form, synthetic 20x20 PNG and
+ REAL Windows #32770 Save As/Open dialogs owned by its own PID.
+- 4/4 stages PASS: cancel export, save ZIP with Hangul filename,
+ cancel import, import Korean-named ZIP into an empty temporary library.
+ Saved ZIP size 535 bytes, PNG SHA-256 unchanged, imported 1 item,
+ failed 0. The script never touches other application controls,
+ real clipboard, or the user's Notro library.
+- Picker uses current pywebview FileDialog enum with lazy fallback to
+ deprecated constants only for older versions.
+- Real Windows WebView2 synthetic 1x1-image gallery benchmark, one run:
+ 100 DOM items rendered in 1ms/search 0ms;
+ 1000 rendered in 8ms/search 1ms;
+ 5000 rendered in 39ms/search 4ms.
+ All expected DOM cells rendered without JavaScript errors.
+ Reproduce: python scripts/qa_native_webview.py
+ The benchmark does NOT measure real GIF decoding or 5000 mixed-size
+ animated assets' memory usage.
+- Plane parent NOTRO-5 real Discord clipboard/attachment QA remains open.
+ No live Discord draft/message was created, pasted, or sent.
+- SignPath/signing, SmartScreen and installer execution remain unverified.
+
+
+## Plane state and next acceptance
+
+After the real file-dialog and native DOM run, Plane workspace soursea,
+project NOTRO has 21 tracked tasks: Done 3, QA 11, In Progress 1,
+To Do 4, Backlog 2. NOTRO-20 and NOTRO-21 are Done; NOTRO-5
+was moved To Do -> In Progress, not Done, because real Discord draft
+clipboard integration and GUI installer runtime QA remain unresolved.
+The resume-safe Plane sync script continues to preserve manual status,
+priority and ownership when refreshing issue descriptions.
+
+
+## Final unsigned QA build for native dialog changes (e26122b)
+
+- Manual GitHub Actions build 38499645717: Windows tests, executable, Inno
+ Setup, SHA256 and short-lived QA artifact upload succeeded. Published
+ release step SKIPPED; no v2.11.0 release/tag created.
+- Downloaded 17,091,762-byte NotroSetup.exe and matched published SHA256:
+ 1b91f47abce7daf9d3f0a772cba065ba951f0885e22d5557241662ba9855ba9a.
+- Windows VersionInfo ProductName Notro, FileVersion 2.11.0;
+ Authenticode NotSigned. The installer was NOT executed.
+- Main branch-protection API returned 404 (unprotected). SignPath Foundation
+ reputation/manual review and Azure Public Trust alternatives are documented
+ in docs/research/2026-10-13-notro-code-signing-readiness.md.
+ No third-party certificate/account enrollment occurred. Real Windows
+ installation, SmartScreen and Discord draft/paste tests remain open.
+
+
+
+## October 14: preferences backup implementation and native QA
+
+- New local JSON settings backup, with allowlisted settings, 128KiB validation,
+ opt-in absolute watched-folder paths, no images/Captures/account data.
+ A media pack ZIP remains separate.
+- Import is **preview-only first**, then an explicit approval action with
+ a per-review token and SHA-256 file consistency guard. Missing watched
+ directories are skipped; current directories are not removed. Automatic
+ send/capture are both disabled and startup is left unchanged on restore.
+- Picker Settings contains export, import, optional path checkbox, and
+ a review modal in EN/KO/JA/ZH/ES. The checkbox defaults to OFF.
+- Real isolated Windows #32770 dialogs PASS 8 stages: four ZIP media
+ import/export steps and four settings backup/restore preview steps, including
+ Hangul filenames. The test intercepts registry writes in memory and does
+ not modify any actual settings, clipboard, real media or Discord.
+- Native Windows WebView2 fixture confirms settings controls are visible,
+ path checkboxes unchecked, and 100/1k/5k synthetic renderer/GIF/packs
+ still PASS. Python unit suite 438 passed locally, Ruff clean.
+- User explicitly declined all Signing on 2026-10-14. Signing-dependent
+ tasks NOTRO-6 and NOTRO-22 are deferred to Backlog and no enrollment,
+ certificate, signing, protected-branch policy or release operation was done.
+- NOTRO-15 moves into QA; real user import acceptance and application
+ restart/locales/hotkey behavior remain unverified; no release/merge.
diff --git a/docs/qa/2026-10-17-manual-release-acceptance.md b/docs/qa/2026-10-17-manual-release-acceptance.md
new file mode 100644
index 0000000..ccb1159
--- /dev/null
+++ b/docs/qa/2026-10-17-manual-release-acceptance.md
@@ -0,0 +1,98 @@
+# Notro v2.11.0 — manual Windows release acceptance
+
+Status: NOT EXECUTED YET. This is the exact human QA that remains after
+the passing synthetic tests and disposable Windows GitHub runner upgrade.
+The user decided NOT to sign code. Never misrepresent an unsigned installer
+as having trusted publisher verification.
+
+## Test environment and privacy
+
+Record Windows 10/11 build, GPU/display DPI, Windows language, Discord
+desktop version, Notro version and whether WebView2 Runtime is installed.
+Use a spare Windows user profile or VM where practical, not the main
+development profile with personal working media.
+
+Before an install/upgrade, make a private local backup of any existing
+Notro data and settings; do not attach it to public issues. Do not upload
+real chat screenshots, private messages, account tokens, clipboard history,
+photos of people or contact information to QA reports.
+
+Use an approved, deliberately **unsent** private Discord draft, or simply
+a separate harmless file-editing application for initial clipboard tests.
+Do not send a Discord message automatically or click Send for this test.
+Keep optional auto-send switched OFF throughout the release acceptance.
+
+The only candidate artifact for this checklist is from the named, reviewed
+GitHub Actions run; download it afresh and verify its released SHA-256
+against the manifest. If Windows warns of an unknown publisher or blocks
+the unsigned binary, **record the result and stop**. Do not disable
+SmartScreen/Defender just to make the test green.
+
+## Checklist (mark only after observing the real behavior)
+
+### A. Installer, lifecycle, ownership
+
+- [ ] A1: Obtain explicit QA permission to run the unsigned candidate.
+ Verify SHA256, ProductName Notro and 2.11.0 file version.
+- [ ] A2: Start installer normally on Windows 11. Screenshot the actual
+ warning/setup experience without private desktop data.
+- [ ] A3: Start installer normally on Windows 10, with WebView2 present.
+ Record no-network/WebView2-missing fallback separately.
+- [ ] A4: With Notro running from a fresh profile, find the tray icon,
+ open its menu, check Pause/Resume, selected upload preset, and language.
+- [ ] A5: From an existing v2.10.3 installation, upgrade to 2.11.0.
+ Verify preserved personal library, categories/favorites and settings.
+ Note the disposable hosted-runner synthetic upgrade already PASSED.
+- [ ] A6: Uninstall and check executable removal but intentionally retained
+ local library/preferences; ensure no orphan tray/ffmpeg processes.
+- [ ] A7: Confirm Windows startup is opt-in and stays disabled until selected.
+ Do not change the user's real startup tasks without permission.
+
+### B. Picker/clipboard behavior (no public Discord messages)
+
+- [ ] B1: Use the hotkey to show the native picker and close with Escape.
+ Keyboard search, arrow keys, Enter selection and focus restore work.
+- [ ] B2: Switch to a dedicated empty *unsent* Discord draft without
+ exposing existing chats to QA. Use a rights-cleared PNG, JPG and GIF.
+ Confirm the attachment preview appears after user-directed paste.
+ **Do not press Send.**
+- [ ] B3: Test 19.5MB, exactly 20MB, 20MB+1 byte and 24MB files.
+ Actual Discord subscription/experiment limits may differ.
+ Inspect pasted filename/size, the pre-attach UI and original integrity.
+- [ ] B4: Switch target window during the picker closing/focus restore.
+ Notro must never send Enter to the wrong window; auto-send stays OFF.
+- [ ] B5: Verify GIF comparison and manual approval/cancel. The library
+ original stays unchanged and preview temp file is cleaned.
+- [ ] B6: Check disabled/hotkey conflict, clipboard-busy application,
+ missing watched folder, and a 5,000-item mixed media library.
+ Collect private performance/RAM evidence with consent.
+
+### C. Backup, media, updater, localization
+
+- [ ] C1: Save and import rights-cleared emoji/sticker/GIF ZIP media pack;
+ corruption and duplicates behave as described.
+- [ ] C2: Save JSON settings backup with paths unchecked, review counts,
+ cancel once, then explicitly apply to an isolated profile; confirm
+ auto-send and auto-capture remain disabled and startup untouched.
+- [ ] C3: Confirm non-ASCII filename and Windows profile path behavior.
+- [ ] C4: Validate English, Korean, Japanese, Simplified Chinese and Spanish
+ text; note any clipped buttons at small window size, 125/150/200% DPI.
+- [ ] C5: With update check explicitly enabled, confirm update notices
+ don't silently install, downgrade or transmit private user data.
+- [ ] C6: Confirm user-readable unsigned/SHA disclosure and an accurate
+ privacy/debug logging procedure, with log path redaction.
+
+## Beta exit thresholds
+
+To invite 5–10 opt-in early testers, require successful clean install/
+uninstall on at least one machine, a working unsent Discord attachment
+preview and no unexpected clipboard loss or background message sending.
+For unrestricted v2.11.0 announcement, require **all mandatory P0
+items A/B and the documented critical C items**, an audited live landing
+page and owned/rights-cleared demos. Record each failing step as a Plane
+NOTRO issue linked to the observed environment and reproduction.
+
+No human step may be marked PASS based only on synthetic WebView2, unit
+tests, CI building an installer, or a GitHub-hosted Windows service session.
+No release tag, merger, public download or marketing post is authorized
+by this checklist alone.
diff --git a/docs/qa/2026-10-17-portfolio-audit-triage.md b/docs/qa/2026-10-17-portfolio-audit-triage.md
new file mode 100644
index 0000000..3c396f3
--- /dev/null
+++ b/docs/qa/2026-10-17-portfolio-audit-triage.md
@@ -0,0 +1,58 @@
+# Notro landing portfolio audit — isolated triage (2026-10-17)
+
+Status: upstream security blocker OPEN; no CI/security bypass or release.
+
+The public Notro page at https://writingdeveloper.blog/projects/notro
+still says 10MB and v2.0. Portfolio metadata Draft PR #36 and the
+separate portfolio dependency Draft PR #33 both fail full CI.
+Other sessions own these repositories, worktrees, and pull requests.
+This investigation did not modify those worktrees or live pages.
+
+## Isolated reproduction
+
+Investigated portfolio PR #33 at source commit 2a4973e in a new,
+independent worktree, without touching any uncommitted portfolio changes.
+
+- Node 22.16.0; npm 10.9.2.
+- npm view braces version: newest available 3.0.3.
+- npm view micromatch version: 4.0.8.
+- npm view eslint-config-next version: 16.4.2.
+- npm audit --json: five HIGH advisories (0 critical/moderate/low).
+- Vulnerable developer dependency chain:
+ eslint-config-next -> @next/eslint-plugin-next -> fast-glob ->
+ micromatch -> braces.
+- Lockfile braces 3.0.3 is affected by advisory
+ https://github.com/advisories/GHSA-vfj7-8cjw-p6xm.
+ npm reports all published braces versions up to 3.0.3 as vulnerable.
+ A patched version was not available through npm at inspection.
+- npm audit fix --force suggests DOWNGRADING eslint-config-next to
+ 14.2.35, not a verified Next.js 16-compatible remedy.
+- PR #33 GitHub Actions:
+ https://github.com/writingdeveloper/portfolio/actions/runs/38500626360
+ Next.js build PASSED; production-only audit (--omit=dev) showed
+ zero known vulnerabilities; **full dev+production audit FAILED**.
+ This does not prove the portfolio or third-party dependencies have
+ no other security flaws.
+
+## Required safety policy
+
+Do NOT disable, silence, omit dev, or lower the existing full audit merely
+to make CI appear green. Do not downgrade Next lint config to v14 without
+a complete Next/React/TypeScript lint migration, and do not conceal a
+known vulnerable braces fork under a new package name.
+
+Safe options are:
+1. Wait for the published patched upstream braces version or an upstream
+ path not pulling in the vulnerable package, then update the lockfile
+ and re-run all CI/build checks.
+2. If urgent, explicitly review and validate an alternative Next 16
+ lint/toolchain dependency graph with equivalent accessibility, React,
+ Next, TypeScript and formatting checks and no security advisories.
+ That is portfolio-wide cross-session work and needs human coordination.
+3. Keep the separate Notro landing PR #36 Draft until the portfolio
+ security check genuinely passes. Independently verify deployed EN/KO
+ metadata, claims and version-matched v2.10.3/v2.11.0 download CTA.
+
+A clean production-only audit does NOT override the failing configured
+full dependency audit. The Notro candidate remains unreleased and the
+user explicitly opted out of installer signing.
diff --git a/docs/qa/2026-10-17-release-readiness.md b/docs/qa/2026-10-17-release-readiness.md
new file mode 100644
index 0000000..e91eea6
--- /dev/null
+++ b/docs/qa/2026-10-17-release-readiness.md
@@ -0,0 +1,426 @@
+# Notro v2.11.0 — Release readiness review (2026-10-17)
+
+Decision: **NO-GO for public v2.11.0 release or broad marketing today.**
+An invited, opt-in preview/beta may be offered after the actual unsigned
+installer smoke, Windows GUI first-run, and Discord *non-sending draft*
+tests pass. NO tag, merge, newsletter blast or external post is approved
+by this review. Signed builds are out of scope by user decision.
+
+## What was inspected
+
+- Notro repository, source, tests and separate feature worktree;
+ GitHub Draft PR #1 vs. published latest v2.10.3.
+- README in EN/KO/JA/ZH/ES, SECURITY.md, CODE_SIGNING.md,
+ THIRD-PARTY-NOTICES.md, CHANGELOG.md, LICENSE, .github issue template,
+ GitHub Actions CI/Release, installer.iss, contributor and PR templates.
+- Current Windows picker native WebView2 fixtures, file dialogues, local
+ media and video compression, secure imports, stored settings and
+ update-handling design.
+- GitHub portfolio Draft PR #36 and public Notro portfolio page.
+- Plane workspace soursea, project NOTRO (details synchronized separately).
+- Discord official file attachment guidance:
+ https://support.discord.com/hc/ko/articles/25444343291031-
+ (free 20MB, Nitro Basic 50MB, Nitro up to 1GB, may vary by account).
+- Hacker News post/comment rules:
+ https://news.ycombinator.com/newsguidelines.html
+
+## Technical evidence at review time
+
+| Area | Evidence | Result |
+| --- | --- | --- |
+| Windows Python suite | 447 tests on source 167e95c | PASS local and Windows Actions CI 39057057625 |
+| Dependency security | pip-audit 2.10.1 with resolved runtime/dev/build requirements | No *known* advisories at audit time; not a malware guarantee |
+| Import ZIP and settings | 8 real native Win32 Save/Open stages, with Korean filenames, isolated profile | PASS |
+| WebView2 | Actual Windows picker, 5 languages, synthetic 5k DOM, GIF compare, settings controls | PASS for synthetic fixtures |
+| Media 20MB boundary | Valid synthetic padded PNGs at 19.5MB, 20MB, 20MB+1B, 24MB | PASS; not real-world photo quality |
+| Real-pixel media | Deterministic synthetic 3000x3000 high-entropy PNG about 27MB | Produced ~7.55MB decoded WebP, source unchanged |
+| Video | Locally generated 5-sec 960x540 video with sine-wave audio, real ffmpeg | Encoded 854x480 @30fps with audio; trim/mute case PASS |
+| 1GB Nitro preset | User-configurable; legacy 500MB remains | Use conservative decimal billion-byte target with 95% safety |
+| Prior installer | Source e26122b, SHA/VersionInfo 2.11.0 validated, unsigned | Compiled only, NOT evidence of running installed app |
+| New installer smoke | Windows GitHub-hosted QA install/exe version/uninstall only | PASS on disposable runner 39057079637 (unsigned, not UI) |
+| Portfolio landing | https://writingdeveloper.blog/projects/notro | FAIL: still says free 10MB, v2.0, absolute ToS-safe |
+| Portfolio security | Draft PR #36 check failed, multiple unresolved dependency PRs | BLOCKED; do not bypass npm audit |
+| Published release | latest GitHub release v2.10.3, v2.11.0 unmerged Draft PR | NOT MATCHED for advertising new features |
+
+## Mandatory P0 gates before broad public v2.11.0 launch
+
+- [ ] **Real Discord non-sending draft interaction** on Windows 10 and Windows
+ 11: PNG/JPG/GIF, focus changes, CTRL+V file attachment preview, clipboard
+ contents restored when applicable; auto-send OFF by default. No public
+ message may be sent by automation or without explicit consent.
+- [ ] **Installer runtime**: disposable Windows 10/11 install, uninstall,
+ upgrade from v2.10.3 with real user data backed up (or synthetic only),
+ first run tray icon/hotkey/keyboard/multimonitor DPI, no startup surprises,
+ WebView2 missing and no-network scenarios. CI installer smoke is only
+ the first sub-gate.
+- [ ] **Version integrity**: merge reviewed candidate to main, CI and release
+ validation pass, publish a version-matched artifact and checksum **only
+ after** manual maintainer authorization; test installer download. All
+ distributed binaries remain UNSIGNED; Windows warnings are disclosed.
+- [ ] **Landing/content**: merge content safely only after portfolio dependency
+ security audit is green; deployed page H1/title/meta/OG and both EN/KO
+ descriptions reflect *published* release and free 20MB, up-to-1GB Nitro.
+ Remove blanket claims of guaranteed Discord Terms compliance.
+- [ ] **Rights-cleared demonstrations**: do not advertise from current
+ demo.gif/picker.png with recognizable third-party anime/character assets
+ until distribution rights are verified. Capture authentic Notro UI with
+ personally owned/rights-cleared simple assets and measure real flows.
+- [ ] **User trust/support**: privacy policy, debug-log redaction, reproducible
+ checksum, unsigned notice, user-run installer permissions and private
+ bug-report channel all manually reviewed.
+
+## Important but beta-tolerable gaps (P1)
+
+- Performance/peak memory with thousands of real animated GIFs (5k synthetic
+ placeholders alone do not prove this).
+- Video compression speed/quality for long clips, high-resolution games and
+ unusual codecs. Synthetic 5-sec success is not a gameplay benchmark.
+- Full first-run usability median from at least 5 consenting people, Korean
+ and English; exact completion criteria must be measured, not estimated.
+- Accessibility, keyboard-only file dialogues, high-contrast themes, captions.
+- Portable pack/backup import error recovery with corrupt data and data-copy
+ permission variations on Windows 10 and a non-English Windows user profile.
+- Third-party licensing and third-party art in released marketing screenshots
+ reviewed with concrete distribution permissions.
+
+## GitHub/documentation corrections in progress
+
+- Contributor guide and PR template explicitly distinguish mocks vs reality.
+- CI and unsigned manual Release workflow now fail when pip-audit finds known
+ vulnerabilities, and run PNG/real-pixel threshold preflight.
+- PyInstaller/QA audit tooling pinned independently of runtime dependencies.
+- Tag-based public release restricted to commits already reachable from main.
+- QA installer uses a disposable GitHub runner, verifies version, install,
+ no unexpected Notro launch, removal. Does not install on the user's PC.
+- Five localized docs/limit selector updated for official Nitro 1GB maximum,
+ legacy 500MB option kept, upload target bounded conservatively.
+- Old Show HN draft contains unsupported compression examples and claims:
+ marked as reference-only; no AI-written HN submissions or auto-posting.
+- Signing is explicitly NOT pursued (user decision).
+
+## Recommended rollout
+
+1. Stabilize code, retain Draft PR, require CI green; record all findings in Plane.
+2. Pass unsigned installer smoke and actual user-driven draft flow.
+3. Invite 5–10 consenting testers with an accurate warning (no production mass
+ promotion). Record installation failures, time-to-first-paste, file output
+ verification and opt-in privacy feedback without in-app telemetry.
+4. Fix P0 failures; collect permission-cleared demo media.
+5. Publish manually with checksum and caution; verify external download.
+6. Update audited landing and only then try authentic manual marketing posts
+ matching each community's rules. No guaranteed traction claims.
+
+This readiness review is an engineering judgment, not a Discord or Microsoft
+certification and not a recommendation to bypass Windows security warnings.
+
+
+## 2026-10-17 verified Windows Actions QA result
+
+- Source: 167e95c (unreleased Draft PR #1).
+- GitHub Actions CI run:
+ https://github.com/writingdeveloper/Notro/actions/runs/39057057625
+ **PASS** (dependency advisory audit, Python lint, media threshold/
+ real-pixel compression preflight, 447 unit/regression tests).
+- Manual unsigned installer workflow:
+ https://github.com/writingdeveloper/Notro/actions/runs/39057079637
+ **PASS**: Windows PyInstaller/Inno installer; separate disposable Windows
+ runner installed and silently uninstalled candidate, checked EXE version,
+ and prevented unwanted post-install startup. SHA-256 generated; QA artifact
+ uploaded for 3 days; Publish release explicitly SKIPPED.
+- @4080 downloaded the QA artifact into a disposable test location but did
+ **not execute or install** it on the user's machine. Verification:
+ NotroSetup.exe 17,199,976 bytes, ProductName Notro, FileVersion 2.11.0,
+ SHA-256
+ 4f58b3ae6462d58818b9b8f0f00f85ce34b2879dc1082306c483de9b521315cc
+ (matching CI checksum file), Authenticode **NotSigned** as expected.
+- User explicitly opted out of signing. No certificate, issuer, tag or public
+ release was created. Signed Windows reputation is not a hidden requirement
+ but unsigned warning and initial install friction are still disclosure and
+ human-test requirements.
+- **Remaining NO-GO**: real user GUI first run, v2.10.3 upgrade, Windows 10,
+ actual Discord non-sending draft Ctrl+V/focus, portfolio dependency audit +
+ stale landing page, and rights-cleared authentic marketing media.
+
+
+## 2026-10-17 verified public-to-candidate upgrade
+
+Source commit 413623c, GitHub Actions CI 39058070961 **PASS** (449 tests)
+and unsigned manual build 39058080501 **PASS**.
+
+CI logs show these named checkpoints in chronological order:
+
+- NOTRO_QA_BASELINE_VERIFIED 2.10.3
+- NOTRO_QA_UPGRADING_UNSIGNED 2.11.0
+- NOTRO_QA_BASELINE_TO_CANDIDATE_UPGRADE_PRESERVED_DATA
+- NOTRO_QA_UPGRADE_UNINSTALL_PASSED
+- NOTRO_QA_INSTALL_UNINSTALL_PASSED
+
+The baseline public v2.10.3 installer and its released SHA-256 were downloaded
+and matched on a disposable hosted Windows runner, then replaced in-place with
+v2.11.0. A synthetic profile file survived upgrade and intentional uninstall;
+the actual personal library was never accessed. The old installer launches
+Notro even when unattended, so the QA script stops only the ephemeral
+runner's Notro process. PowerShell's process-tree wait bug was fixed by
+bounded waits on the installer process itself.
+
+After the workflow succeeded, @4080 downloaded the unsigned QA artifact into
+an isolated temporary folder WITHOUT executing/installing it. Evidence:
+NotroSetup.exe size **17,196,309 bytes**, SHA-256
+df91f3e77b7e81405511f767ef7f2f6f3a0c1f376f5c96d0b75f56680671ba73
+(matched same-run checksum), Windows ProductName Notro, ProductVersion 2.11.0
+and Authenticode **NotSigned**. The Publish release step was skipped as
+designed and latest public is still v2.10.3.
+
+This closes the **synthetic automated upgrade** sub-gate only. Windows 10/11
+interactive GUI first-run, real installed app tray/hotkey and Discord
+non-sending attachment drafts remain hard launch blockers. Exact procedure:
+docs/qa/2026-10-17-manual-release-acceptance.md.
+
+
+## Native WebView2 picker demo with owned art, verified 2026-10-17
+
+- Generated 18 original geometric emoji, four original stickers and
+ three original animated GIFs. Assets came only from Pillow primitives in
+ scripts/record_owned_native_demo.py; no external download, Discord artwork,
+ user assets or stock imagery were used.
+- Recorded **nine actual WebView2-rendered UI scenes, 15.9s**, to
+ docs/demo-owned-webview.gif (approximately 390KB) and native still
+ docs/picker-owned-webview.png. Source window content was captured through
+ CoreWebView2.CapturePreviewAsync, not by photographing the desktop.
+- Scenes cover gallery/favorites, search using the real input handler,
+ arrow-key JS navigation, sticker tab, animated GIF tab, settings with
+ both auto-send and auto-capture OFF, and pack/settings backup controls.
+- First test revealed test-only profile settings were reading the local
+ Windows registry: in the initial screenshot auto-send was displayed as
+ enabled. This was **a QA isolation problem, not an auto-send action**.
+ Corrected the fixture to intercept registry getters/setters, required
+ both safety toggles OFF before capture, and regenerated the footage.
+ The user's settings were not changed. Also updated prior native WebView2
+ smoke fixture to isolate registry reads/writes.
+- Updated EN/KO/JA/ZH/ES README hero images/captions to the owned UI demo.
+ Historical docs/demo.gif/picker.png still contain recognizable third-party
+ character images and are excluded from present README promotions.
+- This removes the **picker-UI owned-art sub-gate only**. Real Discord
+ unpublished attachment, human Windows installer footage, large-video
+ evidence, rights/metadata review for the live portfolio and external
+ marketing remain NO-GO. Separate manual QA in
+ docs/qa/2026-10-17-manual-release-acceptance.md is still necessary.
+
+
+## Source fa3aff1: owned UI evidence and real disposable-runner clipboard QA
+
+- Source code: fa3aff1. GitHub Windows CI run 39072813556 **PASS**,
+ including 454 Python tests, Ruff, pip-audit and real-pixel media preflight.
+- Manual unsigned Windows run:
+ https://github.com/writingdeveloper/Notro/actions/runs/39072830662
+ **PASS**. Native Win32 clipboard log includes runner_only=true,
+ filesystem_unicode=true, unicode_text=true, marker=true,
+ stale_guard=true, passed=true, errors=[].
+ Tested actual CF_HDROP with Hangul temporary file path through
+ DragQueryFileW, Unicode text, Notro marker and protection against
+ overwriting a newer copy. Synthetic clipboard was cleared at end.
+ No Ctrl+V/Enter or Discord client interaction took place.
+- Same disposable-runner workflow again PASSED pinned PyInstaller/Inno
+ build and verified public v2.10.3 -> 2.11.0 upgrade with a synthetic
+ profile marker retained after upgrade and uninstall. Publish release
+ step SKIPPED.
+- The unsigned QA installer was downloaded for HASH/metadata validation
+ into a temporary directory on @4080, not executed on the user's PC.
+ NotroSetup.exe: 17,199,943 bytes; SHA256
+ d8ce5174c3e5462d03cc3f524a6bf861560becaf36ef91b1e8e6f399bf0b1848,
+ matching the generated manifest; ProductVersion=2.11.0 and
+ Authenticode=NotSigned.
+- New source-owned actual WebView2 UI animation is in five-language
+ READMEs; auto-send and capture are explicitly OFF in the test fixture
+ rather than inheriting registry settings. No third-party character
+ media, user clipboard, chats or desktop windows are captured.
+- Plane NOTRO-27 owned native UI demo and NOTRO-28 hosted Win32 clipboard
+ QA are Done; NOTRO-25 full marketing evidence remains In Progress and
+ NOTRO-5 real Discord unsent draft remains In Progress.
+- No signing, main merge, production release/tag or public marketing post.
+ Published latest GitHub release remains v2.10.3.
+
+Critical NO-GO gates remain: actual user-approved non-sending Discord
+attachment preview/focus and clipboard behavior, Windows 10/11 human GUI
+first run/offline conditions, portfolio dependency-audited live landing,
+and approved authentic screenshot/video attachment evidence.
+
+
+## 2026-10-17 next pass: original-target paste safety and parallel growth research
+
+- Previous PickerController.select wrote CF_HDROP before establishing that
+ the original target could regain focus, risking replacing the clipboard
+ even though there was nowhere safe to paste. The new code checks the target
+ before clipboard mutation, compares clipboard sequence numbers (also for
+ URL/text copies) to preserve newer user data, rechecks focus after commit,
+ and only counts a Win32 Ctrl+V operation when SendInput accepted all keys.
+ Accepting the key sequence is NOT proof of target application acceptance.
+- Unit tests now cover focus failure before/after copy, newer-copy race,
+ failed SendInput and bad media preparation, without touching a real user
+ clipboard; five localized explanations distinguish outcomes.
+- New manual GitHub-hosted Windows test scripts/qa_runner_owned_target_paste.py
+ is guarded to refuse execution outside disposable runner. It exercises
+ owned Tk Win32 target window -> PickerController.select -> real CF_HDROP
+ -> SendInput Ctrl+V -> owned entry binding, invalid-target copy
+ preservation and newer-copy race. It explicitly blocks Enter/auto-send
+ and never opens Discord. Record actual remote status before marking Done.
+- In parallel, segmentation, competitor alternatives, distribution ethics,
+ three controlled beta tasks, 5-10 volunteer cohort thresholds and
+ aggregate privacy-safe measurement are documented in
+ docs/marketing/2026-10-17-research-validation-first-growth.md and
+ docs/marketing/2026-10-17-beta-user-scorecard.md.
+- 2026-10-17 GitHub metrics baseline: 0 stars, 0 forks, 35 public v2.10.3
+ installer asset downloads (not unique users), trailing 14-day GitHub repo
+ views 13 / unique visitors 8. These are NOT installed/active users.
+ writingdeveloper.blog/projects/notro still has stale 10MB/v2.0 claims,
+ portfolio Draft PR #36 still fails GitHub npm audit.
+- Neither marketing research nor new QA triggers a release, a community post,
+ a Discord message or a code-signing flow.
+
+
+## 2026-10-17 source 77689d6: owned-target REAL Win32 paste QA PASS
+
+- The first manual workflow attempt failed because the test harness depended
+ on pywin32/win32gui not installed in plain GitHub-hosted Python. Replaced
+ the unnecessary QA dependency with Windows native ctypes.FindWindowW;
+ no runtime dependency or actual Discord client was changed.
+- [GitHub Windows CI 39075255709](https://github.com/writingdeveloper/Notro/actions/runs/39075255709)
+ PASSED: 465 tests, Ruff, pip-audit and real 20MB media preflight.
+- [Manual QA workflow 39075263021](https://github.com/writingdeveloper/Notro/actions/runs/39075263021)
+ PASSED, including real original-target Win32 focus and SendInput:
+ NOTRO_OWN_WINDOW_PASTE_QA reports runner_only=true, owned_window=true,
+ foreground_file_ctrl_v=true, lost_target_preserved=true,
+ newer_copy_preserved=true, auto_send_called=false, errors=[],
+ passed=true. Native CF_HDROP/Unicode clipboard marker/stale guard
+ QA also passed. The event was delivered to a separately owned Tk window,
+ **not Discord**; no message was sent or user clipboard examined.
+- Same disposable runner successfully built the candidate unsigned EXE/
+ Inno installer, verified a released v2.10.3 -> candidate 2.11.0
+ upgrade, retained a synthetic user profile file and removed the app.
+ Publish release skipped by design.
+- @4080 downloaded the QA installer into a private TEMP directory
+ but did not execute/install on the user's PC. It is 17,199,014 bytes
+ with verified same-run SHA256:
+ 2529718fe90e54b28a8fc96ca03915ebc050bc033f5d9238f055e5580c8e888c.
+ ProductVersion=2.11.0, Authenticode=NotSigned. Public latest still v2.10.3.
+- This closes Plane's *owned-window Windows paste integration* task;
+ user-approved real Discord unsent-draft attachment and human Windows10/11
+ GUI, audited live landing and separate genuine full workflow marketing
+ evidence remain hard public release and outreach blockers.
+
+
+## Hosted Windows installed EXE first-run QA added (status pending actual run)
+
+New script scripts/qa_runner_installed_first_run.py is gated by GITHUB_ACTIONS
+and RUNNER_TEMP, invoked only after candidate Notro.exe has been installed
+on the disposable Windows runner. It starts the REAL candidate executable
+with isolated temporary APPDATA; runner-only registry disables update checks,
+hotkey, auto-send and auto-capture. The test observes only windows belonging
+to the spawned PID and requires native picker window creation, visible first
+run welcome, unchanged startup registration, a live process and isolated
+data folder. It terminates only that spawned process tree before uninstall.
+
+Do not mark GUI installed-executable proof until a GitHub manual QA run
+passes. This is NOT interactive human Windows10/11 use or real Discord
+attachment acceptance. No signing, customer contact or release is involved.
+
+
+## Source 0f4606c — real installed Notro.exe GUI first-run test PASSED
+
+- GitHub Actions CI [39076986269](https://github.com/writingdeveloper/Notro/actions/runs/39076986269):
+ **PASS**, 466 Python tests, Ruff, dependency audit and real pixel
+ upload-threshold preflight.
+- Disposable Windows unsigned manual release [39077002020](https://github.com/writingdeveloper/Notro/actions/runs/39077002020):
+ **PASS**. This run installed a checksum-verified public v2.10.3,
+ upgraded to candidate 2.11.0, then launched the ACTUAL installed EXE
+ in a temporary profile before uninstallation.
+- The real process's own Win32 window enumeration verified a visible
+ **Notro** first-run welcome, created **Notro Picker** window (initially
+ hidden), and live process. Test output additionally confirms
+ auto_send_off=true, auto_capture_off=true,
+ startup_unchanged=true, profile_isolated=true,
+ welcome_marked=true, errors=[], passed=true.
+- It also passed previous real owned-target CF_HDROP/Unicode Ctrl+V,
+ later-copy clipboard preservation, actual hosted installer upgrade,
+ synthetic profile preservation and removal. No real Discord chat, user
+ clipboard or actual main development PC installation occurred.
+- This exact run's unsigned QA NotroSetup.exe was downloaded to an
+ isolated local TEMP directory **without executing it** for independent
+ validation: 17,203,616 bytes, SHA-256
+ b6f8ac78eae51c25a77cafda6f217351d8ce58fb750629e8e771e469dbd94cb7
+ (matches release-run checksum manifest), Windows ProductVersion 2.11.0,
+ Authenticode NotSigned. Release publish skipped; v2.10.3 remains public.
+- This closes the **automated installed first-run window** sub-gate
+ (NOTRO-32), not full human Windows 10/11 GUI/SmartScreen/accessibility,
+ first actual Discord non-sending draft, portfolio security audit or
+ marketing readiness. They remain NO-GO/P0. Signing remains excluded
+ by explicit user decision.
+
+
+## Latest 2026-10-17 Notro readiness follow-up
+
+- @4080 actual native WebView2 popup DPI QA discovered a logical-to-
+ physical positioning bug at 200% display scaling; corrected and
+ verified 362x532 CSS -> 724x1064 physical Win32 frame.
+- Five-language About, accessible keyboard GitHub link, native 330x420
+ compact window and 100/125/150% in-browser text zoom regression
+ passed. Human mixed-DPI/Windows10/11 tasks are still NOT done.
+- Isolated portfolio upstream audit triage found five HIGH advisories
+ through eslint-config-next -> fast-glob -> micromatch -> braces
+ v3.0.3 (npm's latest at test, still affected). Production build and
+ production-only audit passed, but the enforced full security audit
+ remains FAILED. See docs/qa/2026-10-17-portfolio-audit-triage.md.
+ NO portfolio PR/active worktree was altered. Do not bypass CI
+ or advertise the old website as release-ready.
+- Runner-only real Notro installed systray Win32 right-click event
+ simulation is prepared; keep its Plane state in QA until a real
+ Windows Actions workflow PASS, and do not touch the user's
+ tray/global clipboard/Discord sessions.
+- User explicitly does NOT want signing; no certificate, public tag,
+ release, main merge, advertising, or Discord messages were sent.
+
+
+## Installed v2.11.0 actual systray WM_NOTIFY event passed (2026-10-17)
+
+Disposable GitHub Windows run 39093341101 PASSED after splitting first-run
+welcome and returning-user startup. The latter's real pystray window
+received WM_NOTIFY/WM_RBUTTONUP and the branded Notro Tray Menu was observed
+visible, then hidden by a second notification. No simulated global mouse
+or keyboard input was generated and no private clipboard/Discord was used.
+The previous attempts (39092716631, 39093044665) failed only that custom
+systray sub-gate while trying to force-close the first-run welcome; their
+failure was retained transparently, not silently marked green.
+
+The published v2.10.3 -> draft v2.11.0 upgrade and synthetic data
+preservation still passed. Same-run unsigned test installer checksum is
+f277fbf226528a58c39411ce50e0fa4f5fb935b907f52fbf7ef70773b1fd4eb9,
+size 17,223,664 bytes. No public release was published.
+
+Follow-up security hardening: own HWND/PID verification now prevents
+positioning an unrelated Win32 window with the same caption; local
+native WebView2 test and pure regression passed. Packaged verification
+of this final guard must be completed before claiming it part of the
+installed release candidate's fully verified path.
+
+Important remaining blockers: real human Windows 10/11 notifications-area
+mouse/right click on various work areas, mixed 100/200% monitor DPI,
+WebView2-missing fallback by a human, real authorized **unsent** Discord
+file attachment, portfolio full dependency security audit, approved
+landing/demo and human-reviewed version-matched unsigned release.
+
+
+## C7248B3 native systray release verification (2026-10-17)
+
+Exact-source CI 39093635986 PASS and manual unsigned Windows workflow
+39093633727 PASS, with 494 tests; installed candidate v2.11.0's own
+WM_NOTIFY/WM_RBUTTONUP open/close event sequence, first-run / returning-
+user restart, original v2.10.3 -> candidate upgrade, retained synthetic
+local profile and successful uninstall. The production popup now validates
+that the title-matching HWND belongs to its own Notro process, and that
+hardened code was in the same QA installer. @4080 downloaded but did NOT
+execute/install the QA installer; independently verified 17,222,275 bytes,
+SHA256 c122cf31b40607faa7b5e9ff2f6075cfbc72f47ff0634cbe990cdc519240e360,
+ProductVersion 2.11.0, NotSigned. Public Publish release step SKIPPED.
+NO-GO for broad launch remains for NOTRO-5 Discord non-sending attachment,
+NOTRO-24 human Win10/11 and physical mixed DPI, NOTRO-16 portfolio landing
+full audit, version-matched published candidate and human launch review.
diff --git a/docs/qa/2026-10-17-tray-about-ui.md b/docs/qa/2026-10-17-tray-about-ui.md
new file mode 100644
index 0000000..5827cfc
--- /dev/null
+++ b/docs/qa/2026-10-17-tray-about-ui.md
@@ -0,0 +1,211 @@
+# Notro v2.11.0 — native tray menu and About QA (2026-10-17)
+
+## Purpose and release state
+
+The user requested an About view showing program version, GitHub, releases,
+MIT license and environment; and a Discord-inspired dark menu when
+right-clicking Notro's Windows notification-area icon.
+
+This is an independent Notro WebView2 popup, NOT a modification of the Discord
+client. v2.11.0 remains an unmerged Draft PR; v2.10.3 remains latest publicly
+available. No code signing, public release, marketing post or user data changes.
+
+## Implementation
+
+- notro_app/tray_panel.py: About payload uses notro_app.__version__ and
+ fixed source, release, issue and MIT-license URLs. Private ctypes Win32
+ functions position only the owned popup on the active monitor. Fallback
+ is a native About dialog when WebView2 is absent.
+- notro_app/picker/ui/tray_panel.{html,css,js}: independent Notro logo,
+ Discord-inspired #17191f dark background, #5865f2 accent, current
+ version, live status, picker shortcut, monitoring toggle, library,
+ upload limit, language, keyboard shortcut, startup, updates, About
+ navigation and links. No remote assets or third-party data.
+- notro_app/tray.py: intercepts WM_RBUTTONUP for Notro's OWN pystray
+ notification-area icon when a working WebView2 bridge exists; otherwise
+ the classic native Windows context menu still appears, including About.
+ Shares existing menu handlers and validates action names and values.
+- notro_app/app.py: constructs and later destroys the new hidden panel
+ alongside the existing picker. The no-WebView2 branch keeps the
+ original native notification-area UI.
+- notro_app/i18n.py: full copy in English, Korean, Japanese,
+ Simplified Chinese and Spanish.
+- The packaged UI is included by existing PyInstaller add-data settings;
+ the About version and links come from local code, not network requests.
+
+## Native screen captures
+
+- tray-menu-native-preview.png: actual Windows pywebview/Edge WebView2
+ home panel, captured with the own CoreWebView2.CapturePreviewAsync API.
+- tray-about-native-preview.png: actual About panel, including
+ visible version, Windows/WebView2/MIT, unsigned/independent disclosure
+ and GitHub repository link near the top.
+
+These are isolated QA-process screenshots with synthetic state, NOT an
+installed user app screenshot or Discord chat evidence. The capture
+method does not record the desktop or any other process.
+
+## QA commands and verified scope
+
+On an authorized Windows test PC:
+
+ python scripts/qa_native_tray_panel.py --output-dir docs/qa
+
+The native QA fixture creates its own unregistered test icon, simulates its
+WM_RBUTTONUP handler, checks fallback before bridge readiness, opens a real
+native WebView2 popup, navigates to About, verifies v2.11.0/GitHub/unsigned
+information, changes a test-only upload setting, handles Escape navigation
+and closing, and invokes the picker quick action through a fake controller.
+The fixture never reads or changes actual user registry/clipboard/Discord.
+
+Additional source and UI regression suites:
+- tests/test_tray_panel_metadata.py
+- tests/test_tray_panel_actions.py
+- tests/test_tray_panel_ui_contract.py
+- tests/test_i18n.py
+
+## Real bug found and fixed
+
+The draft used pywebview.Window.move(x, y) after window.show().
+On the installed WinForms/pywebview backend, that path passed None for
+unused Win32 SetWindowPos size parameters and raised an ArgumentError.
+The popup now moves via its own private WinDLL user32 function table, using
+the exact owned Notro popup HWND. No global API prototypes are overwritten.
+A fresh native QA run confirms normal open, About, settings and close.
+
+## Still not proven by this QA
+
+- The actual packaged EXE creation of its hidden popup must pass on a
+ disposable Windows GitHub Actions runner before marking packaging done.
+- Human Windows 10/11 right-click experience, SmartScreen warning, DPI,
+ multiple monitors and keyboard accessibility need human acceptance.
+- A Discord-approved non-sending draft attachment still needs separate
+ user-authorized testing (Plane NOTRO-5).
+- The portfolio landing security audit, version-matched public download
+ and human release authorization remain P0.
+- The user opted out of signing. Do not apply for certificates or bypass
+ Windows protections.
+
+
+## Successful Windows CI and packaged EXE check
+
+- GitHub CI source commit 461db39:
+ https://github.com/writingdeveloper/Notro/actions/runs/39085969840
+ PASSED, 485 unit/regression tests, Ruff and dependency audit.
+- Manual unsigned candidate workflow:
+ https://github.com/writingdeveloper/Notro/actions/runs/39085985603
+ PASSED, actual public v2.10.3 -> candidate 2.11.0 upgrade, synthetic
+ APPDATA preserved, real installed EXE launched. Its own process window
+ enumeration includes a **hidden Notro Tray Menu** window alongside
+ the hidden Notro Picker and the visible first-run Notro welcome.
+ Auto-send and capture OFF, Windows startup unchanged. Uninstaller
+ succeeded, public release publishing SKIPPED.
+- Same-run unsigned NotroSetup.exe was downloaded for checksum inspection
+ without executing on the user's active PC. Size 17,215,650 bytes; SHA256
+ 62593f9b66f5ae6e568ef14b6b674c68934a69523e2f860d70503b8c85c23169,
+ matching official QA run manifest; file ProductVersion 2.11.0, publisher
+ signature NotSigned (expected by user decision). Candidate still Draft PR.
+- Plane NOTRO-34 About and NOTRO-35 styled tray are Done for source/native
+ QA and packaged hidden-window smoke; a person's actual Windows 10/11
+ right-click accessibility/DPI/SmartScreen review is separate NOTRO-24.
+ Real Discord unsent-file attachment is separate NOTRO-5.
+
+
+## Follow-up: DPI-safe positioning and smaller display QA
+
+- On @4080 the real Win32 Notro test window runs at 200% DPI. During
+ compact-window testing, the draft passed logical WebView2 width/height
+ directly to physical Win32 SetWindowPos and the panel unexpectedly
+ collapsed from intended 362x532 CSS px to 310x320 CSS px. The previous
+ minimum-window setting masked this real DPI bug.
+- Fixed by requesting native GetDpiForWindow on the owned popup's HWND
+ and scaling CSS layout to physical pixels before SetWindowPos.
+ Independently verified actual 192 DPI, browser 362x532 CSS and
+ real Windows frame 724x1064 physical pixels. 100% and 200% conversion
+ have regression coverage.
+- Added 310x320 logical minimum and a native-menu fallback when
+ monitor work area is smaller than that minimum; no clipping
+ attempt is made in the impossible work-area case.
+- Repeated native @4080 WebView2 QA with five localizations,
+ keyboard navigation/accessible GitHub focus, simulated in-browser
+ 100/125/150% zoom (not real monitor DPI), and a *real Win32*
+ 330x420 logical pixel compact popup. The About license link
+ remains horizontally reachable after scrolling. These checks passed.
+- The no-WebView2 native Windows About dialog now uses current
+ language strings rather than fixed English. No real user setting
+ or clipboard was modified.
+- **NOT yet tested:** moving a real installed Notro popup between
+ two monitors of different 100%/200% native DPI, Windows 10
+ multi-monitor taskbar placement and physical user right-click.
+ These remain within NOTRO-24.
+- New hosted runner QA sends only an OS WM_NOTIFY/WM_RBUTTONUP message
+ to the registered systray window of the *test-installed Notro EXE*
+ and observes the owned popup opening/closing. It is **pending**
+ actual remote PASS and must never be conflated with human input.
+
+
+## Isolated installed EXE notification-area message dispatch PASSED
+
+After the previous first-run modal prevented a naive external WM_CLOSE from
+dismissing the welcome, the disposable-runner QA now tests two distinct
+application starts on the SAME throwaway profile: first verifies the welcome
+and hidden picker/tray popup, then terminates ONLY that test process tree
+and reopens the installed candidate with its already-written welcome marker.
+
+In the second, returning-user start it enumerates top-level Win32 windows
+owned by the new process ID, finds its own pystray Notro*SystemTrayIcon
+class, then posts native WM_NOTIFY / WM_RBUTTONUP to its own icon HWND.
+A second notification verifies the owned Notro Tray Menu closes again.
+
+- Source 0b81b17 (before subsequent title/PID hardening).
+- Manual unsigned GitHub Actions run:
+ https://github.com/writingdeveloper/Notro/actions/runs/39093341101
+ PASSED with real_tray_notification_open=true,
+ real_tray_notification_close=true,
+ returning_user_process=true,
+ returning_user_no_welcome=true,
+ returning_user_tray_created=true, errors=[], passed=true.
+- Actual v2.10.3 -> v2.11.0 upgrade, synthetic profile retention,
+ installer/uninstaller, welcome/picker, default safety switches and
+ startup state all passed; public Publish release was skipped.
+- Independently downloaded run's QA installer into TEMP without executing it
+ on the user's active PC: NotroSetup.exe size 17,223,664 bytes; SHA-256
+ f277fbf226528a58c39411ce50e0fa4f5fb935b907f52fbf7ef70773b1fd4eb9,
+ matching the run's checksum manifest, version 2.11.0, NotSigned.
+- Another defensive source change checks the title-matched tray popup
+ HWND is owned by the current process before GetDpiForWindow or SetWindowPos.
+ Actual native @4080 QA passed; until subsequent packaged-run validation,
+ that guard itself is marked a source-level verification.
+- This proves real Windows event dispatch to the installed EXE in a
+ hosted disposable service session, not an actual person's physical
+ mouse action, two-monitor taskbar use, SmartScreen approval, or Discord.
+
+
+## FINAL: own-HWND/PID-safe packaged tray QA green
+
+Final production-source security patch c7248b3 verifies the target HWND
+belongs to its OWN Notro process before positioning with GetDpiForWindow
+and SetWindowPos. It prevents accidentally moving an unrelated window
+with the same caption. Actual native @4080 200% DPI/5 locales/keyboard/
+small-window test PASS and all 494 local tests PASS.
+
+- Exact-source Windows CI:
+ https://github.com/writingdeveloper/Notro/actions/runs/39093635986
+ PASS (494 tests, lint, dependency audit and upload media regression).
+- Exact-source disposable Windows installer QA:
+ https://github.com/writingdeveloper/Notro/actions/runs/39093633727
+ PASS, showing real installed EXE first-run welcome, then returning-user
+ start with no welcome, right-click notification to its own registered
+ pystray HWND: menu visibly OPEN then CLOSED, no failed stages. Upgrade
+ from official v2.10.3 and dummy data preservation/uninstall PASS.
+- Independent QA artifact verified WITHOUT installing it on @4080:
+ NotroSetup.exe 17,222,275 bytes; SHA256
+ c122cf31b40607faa7b5e9ff2f6075cfbc72f47ff0634cbe990cdc519240e360
+ matches same-run manifest; ProductVersion 2.11.0, Authenticode NotSigned.
+ Published release was SKIPPED; public latest is v2.10.3.
+
+Plane NOTRO-36 is Done for **packaged Windows own-tray message QA**, not
+human right-click. NOTRO-24 still tracks human Windows10/11 install,
+multi-monitor physical 100%/200% DPI, real mouse accessibility and
+no-WebView2 fallback. NOTRO-5 remains a separate real Discord **unsent
+draft attachment** P0. No signing, public release, or account interaction.
diff --git a/docs/qa/2026-10-18-data-integrity-and-onboarding.md b/docs/qa/2026-10-18-data-integrity-and-onboarding.md
new file mode 100644
index 0000000..d80181c
--- /dev/null
+++ b/docs/qa/2026-10-18-data-integrity-and-onboarding.md
@@ -0,0 +1,84 @@
+# Notro v2.11.0 — malformed-data recovery and first-use GUI QA
+
+Date: 2026-10-18
+Status: isolated Windows source QA and hosted unsigned EXE recovery QA PASSED. The user's live v2.10.3
+app is unchanged; no user library was loaded into this QA.
+
+## Real defects reproduced before fixes
+
+1. A ZIP media pack manifest with a list or object as type caused an
+ unhashable TypeError instead of a predictable media pack error.
+2. A truncated or damaged ZIP raised raw zipfile.BadZipFile.
+3. Settings JSON with a list/dict as watched-folder default_type caused
+ TypeError instead of returning a safe settings error.
+4. library.json/folders.json with valid JSON but malformed nested types
+ could crash startup or break the in-memory collections.
+5. Native pywebview Welcome exposed its WinForms window object through
+ a public js_api field. JavaScript bridge inspection recursively
+ traversed WinForms AccessibilityObject.Bounds.Empty indefinitely,
+ preventing the actual Try Picker button from initializing.
+6. The same public native window reference existed in video confirmation,
+ trim and cancellation API.
+
+All issues were reproduced with temporary data and separate Windows QA
+WebView2 processes, not Discord or user media. Focused tests failed
+before the fix and passed afterward.
+
+## Code changes
+
+- notro_app/packs.py: type validation, archive byte-size preflight and
+ uniform PackError conversion for corrupt/unsupported ZIP input.
+- notro_app/settings_backup.py: reject watched-folder kind values that
+ aren't one of the supported strings.
+- notro_app/library.py: validate stored item/folder schemas, back up
+ malformed JSON byte-for-byte to local .bak, safely start empty without
+ deleting cached media assets.
+- notro_app/welcome.py and notro_app/video_window.py: keep WinForms Window
+ objects in private js_api fields rather than publicly traversable ones.
+- scripts/qa_native_file_dialogs.py: 10 actual Windows Open/Save scenarios
+ (8 prior + 2 malformed import), stable error and no user data mutations.
+- scripts/qa_native_welcome_flow.py: real Try Picker click opens own
+ native picker with synthetic image, independent Dismiss closes only.
+- scripts/qa_native_video_dialog.py: actual trim/mute/replan, Confirm and
+ Close buttons work via native WebView2 bridge; no encoding/clipboard.
+- tests/test_corrupt_data_recovery.py plus
+ tests/test_webview_api_recursion.py: regression coverage.
+
+## Completed Windows QA
+
+Full local Python suite: 528 passed, Ruff clean.
+Native Windows file dialogs: 10/10 stages PASS, no errors.
+Native Welcome/Try Picker/Dismiss: all true, no errors.
+Native video confirmation/trim/mute/close: all true, no errors.
+
+Reproduce with:
+ python -m pytest -q
+ ruff check notro_app tests scripts
+ python scripts/qa_native_file_dialogs.py
+ python scripts/qa_native_welcome_flow.py
+ python scripts/qa_native_video_dialog.py
+
+## Verified hosted GitHub Windows QA
+
+- Source commit aeb8590 (unreleased Draft PR).
+- GitHub CI: https://github.com/writingdeveloper/Notro/actions/runs/39162457248
+ PASS: 528 tests, lint, pip-audit and image preflight.
+- Manual unsigned Windows QA run:
+ https://github.com/writingdeveloper/Notro/actions/runs/39162481015
+ PASS: real Welcome/Try Picker/dismiss and video trim/confirm/cancel stages,
+ native clipboard and isolated Ctrl+V, compiled EXE/Inno installer,
+ v2.10.3 -> v2.11.0 upgrade and synthetic data retention/uninstall.
+- Actual installed EXE started with invalid temporary library.json and
+ folders.json, preserved both source files byte-for-byte as .bak:
+ invalid_library_backed_up=true, invalid_folders_backed_up=true.
+ Live process, Welcome, Picker, hidden Tray and systray right-click
+ verified; automatic send/capture remain OFF.
+- @4080 downloaded the same-run unsigned installer for integrity validation
+ but did NOT execute/install it on the user's active PC:
+ NotroSetup.exe 17,225,822 bytes, SHA-256
+ f6d7c15f8fd858959123dbd34a671779dcadca3edfd0b33e915eab410368716c
+ matches generated manifest, ProductVersion 2.11.0, NotSigned.
+- GitHub release publish step was skipped. Public latest remains v2.10.3.
+- NOTRO-39 and NOTRO-40 engineering QA can be Done. Human Win10/11 GUI,
+ actual Discord unsent attachment, and measured three-minute human
+ first paste remain unverified separate P0 tasks.
diff --git a/docs/qa/tray-about-native-preview.png b/docs/qa/tray-about-native-preview.png
new file mode 100644
index 0000000..56f6953
Binary files /dev/null and b/docs/qa/tray-about-native-preview.png differ
diff --git a/docs/qa/tray-menu-native-preview.png b/docs/qa/tray-menu-native-preview.png
new file mode 100644
index 0000000..dca5424
Binary files /dev/null and b/docs/qa/tray-menu-native-preview.png differ
diff --git a/docs/research/2026-10-13-notro-code-signing-readiness.md b/docs/research/2026-10-13-notro-code-signing-readiness.md
new file mode 100644
index 0000000..e62c744
--- /dev/null
+++ b/docs/research/2026-10-13-notro-code-signing-readiness.md
@@ -0,0 +1,91 @@
+# Notro Windows code-signing readiness — October 13, 2026
+
+Status: RESEARCHED / NOT SIGNED / NO SIGNING SUBSCRIPTION CREATED. This file
+does not claim that any certificate, SmartScreen reputation, or identity
+verification has been obtained.
+
+## Current verified facts
+
+- Product: writingdeveloper/Notro, MIT, actively maintained Windows utility.
+- Main is **not protected** at inspection time (GitHub CLI authenticated
+ branch-protection API returns HTTP 404, "Branch not protected").
+ Do not switch branch protections or approval requirements without checking
+ concurrent work sessions and confirming who will serve as reviewer.
+- Published production version remains v2.10.3; v2.11.0 is a draft candidate.
+- GitHub Actions builds EXE/Inno Setup and a SHA-256 file, and a manual QA run
+ stores a three-day NOTRO-UNSIGNED-QA artifact. No production release tag
+ was created for v2.11.0.
+- Latest CI+unsigned QA build run for source e26122b:
+ https://github.com/writingdeveloper/Notro/actions/runs/38499645717
+- Downloaded installer tested **without executing it**:
+ NotroSetup.exe: 17,091,762 bytes; ProductName Notro; FileVersion 2.11.0;
+ SHA-256: 1b91f47abce7daf9d3f0a772cba065ba951f0885e22d5557241662ba9855ba9a.
+ Manifest checksum matches; Authenticode is NotSigned.
+- An unsigned SHA-256 checksum published alongside a binary validates transfer
+ integrity relative to that manifest; it does NOT prove publisher identity.
+
+## Signing providers
+
+### First choice: SignPath Foundation (free OSS application)
+
+Official policy: https://signpath.org/terms.html
+Application information: https://signpath.org/apply.html
+
+Notro broadly matches a number of documented prerequisites: MIT license,
+public code, release history, project docs, code signing policy with role
+assignments, declared network activity and an automated GitHub build.
+
+It is NOT guaranteed to be accepted. SignPath explicitly requires a certain
+verifiable reputation for downloadable executables and may reject low-profile
+projects. No proprietary runtime components may be included except permitted
+system libraries; confirm packaging and third-party notices before applying.
+A trusted maintainer must manually approve signing requests, and team MFA,
+review and verifiable source-to-binary provenance are required. REA remains an
+OFFLINE research tool only and is not bundled; Notro does not patch or scan
+Discord. Never represent certificate attribution as granted until accepted.
+
+### Fallback: Microsoft Azure Artifact Signing Public Trust
+
+Official quickstart: https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart
+Signing choices: https://learn.microsoft.com/windows/apps/package-and-deploy/code-signing-options
+Microsoft pricing: https://azure.microsoft.com/en-us/products/artifact-signing
+
+Available for individual developers located in the USA or Canada. Requires an
+Azure subscription, Public identity validation (legal name and billing identity
+must match government ID) and certificate profile, completed by the account
+holder. Basic plan pricing listed at USD $9.99/month. Initial SmartScreen
+warnings can still occur until the signed publisher builds reputation;
+a certificate is not a promise of immediate friction-free installation.
+
+### Alternate: Microsoft Store MSIX
+
+Microsoft documentation lists Store re-signing MSIX as a free path, but
+requires separate packaging, policy and Store submission QA. Notro currently
+uses Inno Setup and should not pretend its EXE is already Store-ready.
+
+## Recommended order, with release gates
+
+1. Keep user data and Discord account untouched; finish real drafted-message
+ compatibility QA, GUI installer QA, user-facing unsigned disclosures.
+2. Protect main with required CI and reviewed PR rules after coordinating with
+ other worktrees and sessions; signing role ownership should be explicit.
+3. Audit released installer dependencies, update third-party notices and ensure
+ REA/Discord proprietary binaries are NOT bundled. Preserve build provenance
+ and avoid building a trusted release from an unreviewed branch.
+4. If broadening distribution, submit SignPath OSS application only through the
+ maintainer-controlled account, complying with its reputation requirements.
+5. If SignPath is not accepted, consider Azure Artifact Signing, requiring
+ verified identity and approval of monthly spending. Do not activate paid
+ resources, sign, or release automatically.
+6. Sign the EXE and installer only after a verified identity/signing workflow
+ exists. Validate Authenticode and timestamp, signed file hashes,
+ Windows 10/11 install/uninstall behavior and SmartScreen separately.
+7. Promote the candidate from Draft PR and Plane QA only after release gates.
+
+## Integrity and security cautions
+
+- Do not infer successful installation from compiling an Inno Setup package.
+- Do not automatically install an unsigned QA artifact on the working PC.
+- Do not bypass npm/pip security audits to produce a green build.
+- Do not grant blanket third-party code-signing approval to an automated agent.
+- Never claim ToS safety or native Discord Nitro entitlement for file attachments.
diff --git a/docs/research/2026-10-13-rea-discord-audit.md b/docs/research/2026-10-13-rea-discord-audit.md
new file mode 100644
index 0000000..069a9cc
--- /dev/null
+++ b/docs/research/2026-10-13-rea-discord-audit.md
@@ -0,0 +1,27 @@
+# REA static audit of installed Discord (2026-10-13)
+
+REA repository: https://github.com/morluto/rea
+Engine: rea-agents 6.4.0; isolated Node 24.12.0 (the @4080 system Node 22.16.0 is below the required 22.19.0).
+
+## Scope and evidence
+
+The existing Discord desktop app was running version 1.0.9261. We analyzed installed local resources/app.asar and modules/discord_desktop_core-1/discord_desktop_core/core.asar **read-only**, without injecting into Discord or reading account/session/message data.
+
+- app.asar SHA-256: 535bcb3de917fa65289519e399ea82ab93d9e4df35f7b437ae8433e5ca9d39ca. REA found 24 graph nodes (Electron main and JavaScript relationships).
+- core.asar SHA-256: 396088f647b3084fc3b6efedd7c5e50eb4d7b9575b817e9b689ff739a54e0c04. REA found 585 nodes, 545 JS modules, a preload bridge and Electron main, with 1,768 edges.
+- Evidence is partial. Neither analysis verifies all renderer behavior, the remote APIs, Discord upload/server limits, nor live clipboard interactions.
+- Raw REA output is stored outside Git under the local Windows temporary Notro/rea-investigation folder; do not publish the original Discord code.
+
+## Conclusions for Notro
+
+1. Electron main/preload boundaries exist. Notro must not depend on undocumented IPC or modify Discord clients. Keep the existing OS-level clipboard + explicit user paste architecture.
+2. Upload policy is better established by Discord's official File Attachments FAQ (updated Aug 13, 2026): free 20 MB, Nitro Basic 50 MB, Nitro 500 MB, subject to user experiments. https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ
+3. Change trigger and compression target were conflated: Notro now leaves 19–20MB input untouched with a free 20MB cap, and uses 95% only for output.
+4. Notro posts emoji as file attachments; it must not promise native Nitro privileges.
+5. Follow-up live QA must test a safe unpublished draft (not private conversations or auto-send), actual PNG/JPEG/GIF/video and focus behavior.
+
+REA command for repeat static analysis: npx --yes --package=node@24.12.0 --package=rea-agents@6.4.0 -- rea analyze-javascript-application "PATH_TO_INSTALLED_ASAR" --json
+
+## Limitations
+
+Static imports and Electron API edges do not prove actual runtime flow. No bypass, interception, token retrieval, client patching or reverse-engineered proprietary code is included in Notro.
diff --git a/docs/show-hn-draft.md b/docs/show-hn-draft.md
index 11a2022..b14666f 100644
--- a/docs/show-hn-draft.md
+++ b/docs/show-hn-draft.md
@@ -1,6 +1,8 @@
# Show HN 게시용 초안
-> 게시 준비용 메모입니다. 게시 후 지우셔도 됩니다.
+> 출시 전 참고 메모이며 그대로 게시하면 안 됩니다. Hacker News는 AI 생성 글과
+> 자동 포스팅을 금지합니다. 실제 QA와 릴리스 후 작성자가 자신의 말로
+> 제목과 첫 댓글을 작성하세요. https://news.ycombinator.com/newsguidelines.html
**작성 원칙:** 읽는 사람이 *자기 상황*을 떠올리게 만드는 것이 목적입니다. 다만 HN은
홍보 문구 냄새에 강하게 반응하므로, 어조는 담백하게 두고 **구체적인 장면과 숫자**로
@@ -21,7 +23,7 @@ Show HN: Notro – custom emoji and oversized uploads on Discord, no client mods
대안:
- `Show HN: Notro – use your own emoji on Discord without patching the client` (74자)
-- `Show HN: Notro – Discord's 10MB limit, handled from your clipboard` (66자) — 압축 쪽만 강조
+- `Show HN: Notro – Discord's 20MB limit, handled from your clipboard` (66자) — 압축 쪽만 강조
**Text 칸:** 비웁니다. 아래 내용을 **게시 직후 첫 댓글**로 답니다 (Show HN 관례).
@@ -30,7 +32,7 @@ Show HN: Notro – custom emoji and oversized uploads on Discord, no client mods
## 첫 댓글
```
-Discord's free upload limit is 10 MB. If you screenshot at any real
+Discord's free upload limit is currently 20 MB (as of August 2026). If you screenshot at any real
resolution you hit it constantly, and the fix is always the same tedious
loop: find the file, open an editor, export it smaller, go back, paste
again.
@@ -39,23 +41,17 @@ Notro removes the loop. It sits in the tray and watches the clipboard, so
an image that's too big is already shrunk by the time you press Ctrl+V.
Under the limit it does nothing at all.
-Game clips work the same way, except it asks first and shows you what
-you'll get:
-
- 52MB · 1:12 · 1080p60 → about 9.5MB · 480p30
-
-You can trim the clip right in that dialog, and that matters more than any
-encoder setting: as a whole clip it has to fall back to 1080p30 to fit, but
-trimmed to the seven seconds you actually wanted, the same budget keeps it
-at 1080p60.
+For oversized game clips, Notro offers a confirmation window with
+estimated output size and trim controls. Encoding takes time, quality
+can degrade, and some clips do not fit. Actual measured examples will
+be added only after validating real output files.
The other half is a hotkey emoji picker. Ctrl+Shift+E anywhere, type to
filter, Enter to paste. If you've collected custom emoji from servers you're
no longer in, or you just want to use your own images without paying
monthly for it, that's the gap it fills.
-What made me write it instead of using what already exists: every other tool
-that does the emoji half patches the Discord client. I didn't want that
+What made me write it: some alternatives to the emoji problem modify the Discord client. I didn't want that
running on my own account, let alone hand it to anyone else. Notro stays
outside the app — it prepares your clipboard and sends a Ctrl+V, the same
class of input automation as the Windows emoji panel (Win+.). It never sees
@@ -76,7 +72,7 @@ you downloaded or build it yourself. And because it works from outside the
client, your emoji arrive as image attachments rather than inline emoji —
that's the trade for not touching Discord.
-No telemetry, no accounts, four documented network endpoints. MIT.
+No telemetry and no accounts; network endpoints are documented, including installation dependencies. MIT.
```
**분량**: HN 첫 댓글로 적당합니다. 더 줄이고 싶으면 "One thing that surprised me"
@@ -94,12 +90,11 @@ No telemetry, no accounts, four documented network endpoints. MIT.
HN은 첫 30분 반응 속도로 프론트 진입이 갈립니다. 미리 정해두세요.
-**"서명도 없는 Windows 바이너리를 왜 받아야 하나?"** ← 거의 확실히 나옵니다
-> Fair, and you don't have to take my word for it. Releases are built by GitHub
-> Actions from a tag, never from my machine, and each ships a SHA-256 next to the
-> installer. The source builds with `pip install -r requirements.txt` and
-> `build.bat`. Signing is the obvious gap — SignPath's OSS programme wants a track
-> record the project doesn't have yet, which is a bit of a chicken-and-egg.
+**"미서명 Windows 바이너리를 왜 받아야 하나?"**
+> Notro is currently unsigned by choice, so Windows can show an unknown
+> publisher warning. CI-built releases include a SHA-256 alongside source
+> code, but that does not substitute for verifying the publisher's identity.
+> Do not disable system protections solely to use this application.
**"Vencord/BetterDiscord와 뭐가 다른가?"**
> Those patch the client, which is what makes true inline emoji possible and also
@@ -108,14 +103,15 @@ HN은 첫 30분 반응 속도로 프론트 진입이 갈립니다. 미리 정해
> reduction in risk — that trade was the whole point for me.
**"Discord가 막을 수 있나?"**
-> There's nothing to block. From Discord's side it's indistinguishable from a
-> person pasting a file.
+> There is no guarantee. Notro doesn't inject into or modify the client
+> and uses normal Windows clipboard/paste. Future Discord changes may
+> break it; this tool isn't approved or endorsed by Discord, and
+> optional auto-send needs separate compatibility and policy review.
**"클립보드를 항상 감시한다는 게 찜찜하다"**
-> Reasonable. It polls the clipboard sequence number every 400 ms and only reads
-> when it changes. Nothing is stored unless you turn on capture saving, which is
-> off by default, and nothing is ever transmitted — it contacts four endpoints
-> total, all listed in SECURITY.md.
+> It polls the clipboard sequence number and reads new data as needed.
+> Auto-save is off by default. Notro documents its network connections
+> and does not transmit clipboard content to a remote server. See SECURITY.md.
**"맥/리눅스는?"**
> Windows only. The clipboard handling, the tray, and the keystroke simulation are
@@ -144,7 +140,7 @@ HN 프론트 진입은 **첫 30분 upvote 속도**로 갈립니다. 미 동부
| 08:00–09:30 | 11:00–12:30 | 16:00–17:30 | 무난. 유럽이 빠지기 시작 |
| 09:30 이후 | 12:30 이후 | 18:00 이후 | 약함. 미국 점심 + 유럽 퇴근 |
-**목표: 화요일 오전 6~8시(PT)에 게시.** 9시 반을 넘길 것 같으면 **수요일 아침으로
+**실제 릴리스와 설치 QA 이후에만 게시.** 시간대 조언은 경험칙입니다. 가능하다면 화요일 오전 6~8시(PT)를 검토. 9시 반을 넘길 것 같으면 **수요일 아침으로
미루는 편**이 낫습니다 — Show HN은 사실상 한 번뿐이라, 약한 창에 태우는 것보다
하루 미루는 비용이 훨씬 쌉니다. 화·수·목 아무 날이나 괜찮습니다.
diff --git a/installer.iss b/installer.iss
index 5de0bb2..4d4f593 100644
--- a/installer.iss
+++ b/installer.iss
@@ -126,13 +126,20 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType:
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "{cm:InstallingWebView2}"; Check: WebView2Downloaded; Flags: waituntilterminated
; 2) 설치가 완전히 끝난 뒤에만 앱을 실행한다(교체 중 실행 방지). postinstall 플래그가
; 없으므로 무인(/VERYSILENT) 자동 업데이트에서도 실행되어 앱이 되돌아온다.
-Filename: "{app}\Notro.exe"; Description: "{cm:LaunchNotro}"; Flags: nowait runasoriginaluser
+Filename: "{app}\Notro.exe"; Description: "{cm:LaunchNotro}"; Flags: nowait runasoriginaluser; Check: ShouldLaunchNotro
[Code]
var
gDownloadPage: TDownloadWizardPage;
gWebView2Downloaded: Boolean;
+{ CI smoke only: skip launching Notro on short-lived hosted runners.
+ Normal installs and user-initiated upgrades still auto-launch as before. }
+function ShouldLaunchNotro(): Boolean;
+begin
+ Result := GetEnv('NOTRO_QA_SUPPRESS_AUTORUN') <> '1';
+end;
+
function WebView2Installed(): Boolean;
var
V: String;
diff --git a/notro_app/__init__.py b/notro_app/__init__.py
index 9185d5a..a76fc39 100644
--- a/notro_app/__init__.py
+++ b/notro_app/__init__.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Notro 애플리케이션 패키지."""
-__version__ = "2.10.3"
+__version__ = "2.11.0"
APP_NAME = "Notro"
diff --git a/notro_app/app.py b/notro_app/app.py
index bbc8f1c..e05bd6c 100644
--- a/notro_app/app.py
+++ b/notro_app/app.py
@@ -135,6 +135,7 @@ def main():
from .hotkey import HotkeyListener
from .picker.assets_server import AssetServer
from .picker.window import PickerApi, PickerController
+ from .tray_panel import TrayPanel
def _resolve_asset(item_id: str):
item = library.resolve(item_id)
@@ -148,6 +149,10 @@ def _resolve_asset(item_id: str):
picker = PickerController(library=library, api=api)
api._ctrl = picker
listener = HotkeyListener(on_hotkey=picker.toggle)
+ # Own frameless WebView2 tray popup. It is created hidden and can only
+ # replace the native Windows context menu after the JS bridge is ready.
+ # On systems without WebView2, the standard pystray menu remains intact.
+ tray_panel = TrayPanel(state=lambda: {}, action=lambda *_: False)
from . import video as _video
from . import video_window
@@ -161,7 +166,8 @@ def _on_quit_extra():
try/except로 감싸긴 하지만, 그건 "죽지 않는다"만 보장할 뿐 "뒤 단계가 실행된다"는
보장하지 못한다. 각 단계를 개별적으로 감싸 하나가 실패해도 나머지는 반드시
실행되게 한다."""
- steps = [listener.stop, asset_server.stop, picker.destroy,
+ steps = [listener.stop, api.discard_gif_preview,
+ asset_server.stop, picker.destroy, tray_panel.destroy,
video_window.destroy_all, # 열린 확인/진행 창부터 닫는다 — destroy()가
# closed 이벤트를 통해 cancelled(+accepted)를
# set해, 그 창에 매인 _work 스레드가 새 인코딩을
@@ -178,6 +184,7 @@ def _on_quit_extra():
icon = tray.build_icon(
monitor, picker=picker, listener=listener, updater=upd,
on_quit_extra=_on_quit_extra,
+ tray_panel=tray_panel,
)
listener.on_register_fail = lambda label: icon.notify(
tr("notify_hotkey_fail", combo=label), APP_NAME)
@@ -376,6 +383,7 @@ def _on_video_oversize(path: str):
monitor.on_video_oversize = _on_video_oversize
picker.create_window()
+ tray_panel.create_window()
icon.run_detached()
combo = get_setting_str("hotkey", "ctrl+shift+e")
if combo != "off":
@@ -389,7 +397,10 @@ def _on_video_oversize(path: str):
from . import welcome
from .hotkey import label_for
combo_label = label_for(combo) if combo != "off" else None
- welcome.create_window(combo_label)
+ # Give Windows time to dismiss the welcome modal before capturing
+ # the previous foreground window, otherwise Ctrl+V would target it.
+ welcome.create_window(combo_label, on_try=lambda: threading.Timer(
+ 0.3, picker.toggle).start())
first_msg = (tr("notify_first_run_picker", combo=combo_label)
if combo_label else tr("notify_first_run"))
threading.Timer(1.5, lambda: icon.notify(first_msg, APP_NAME)).start()
diff --git a/notro_app/clipboard_win.py b/notro_app/clipboard_win.py
index 7369357..783422d 100644
--- a/notro_app/clipboard_win.py
+++ b/notro_app/clipboard_win.py
@@ -5,6 +5,7 @@
import ctypes
import ctypes.wintypes as wt
+import ntpath
import struct
import time
@@ -26,6 +27,21 @@
user32.GetClipboardData.restype = wt.HANDLE
user32.GetClipboardData.argtypes = [wt.UINT]
+# Public Win32 APIs only. These read window/process identity, not Discord's
+# address space, private IPC, authentication state, or message content.
+user32.IsWindow.restype = wt.BOOL
+user32.IsWindow.argtypes = [wt.HWND]
+user32.GetWindowThreadProcessId.restype = wt.DWORD
+user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
+kernel32.OpenProcess.restype = wt.HANDLE
+kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
+kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
+kernel32.QueryFullProcessImageNameW.argtypes = [
+ wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD),
+]
+kernel32.CloseHandle.restype = wt.BOOL
+kernel32.CloseHandle.argtypes = [wt.HANDLE]
+
CF_HDROP = 15
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
@@ -99,12 +115,14 @@ def set_clipboard_file(path: str, guard_seq: int | None = None) -> bool | None:
user32.CloseClipboard()
-def set_clipboard_text(text: str) -> bool:
+def set_clipboard_text(text: str, guard_seq: int | None = None) -> bool | None:
"""URL 등 텍스트를 클립보드에 넣는다 (마커 포함 — Monitor 재처리 방지)."""
data = text.encode("utf-16-le") + b"\0\0"
if not _open_clipboard_retry():
return False
try:
+ if guard_seq is not None and get_sequence_number() != guard_seq:
+ return None
user32.EmptyClipboard()
if not _global_put(CF_UNICODETEXT, data):
return False
@@ -180,24 +198,70 @@ def get_foreground_window() -> int:
return user32.GetForegroundWindow()
-def _send_keys(seq) -> None:
+PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+_DISCORD_DESKTOP_NAMES = frozenset({
+ "discord.exe", "discordptb.exe", "discordcanary.exe",
+})
+
+
+def window_process_name(hwnd: int) -> str:
+ """Get only a target window's executable basename via public Win32 APIs.
+
+ This is a best-effort *accidental-send safety check*, not account or
+ process attestation. A denied, recycled or unknown HWND returns "" so
+ automated Enter will fail closed. Browser Discord needs manual send.
+ """
+ if not hwnd or not user32.IsWindow(hwnd):
+ return ""
+ pid = wt.DWORD()
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
+ if not pid.value:
+ return ""
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
+ if not handle:
+ return ""
+ try:
+ name = ctypes.create_unicode_buffer(32768)
+ length = wt.DWORD(len(name))
+ if not kernel32.QueryFullProcessImageNameW(handle, 0, name, ctypes.byref(length)):
+ return ""
+ return ntpath.basename(name.value).casefold()
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+def is_discord_desktop_window(hwnd: int) -> bool:
+ """Strictly gate optional auto-send to Discord's desktop executable."""
+ return window_process_name(hwnd) in _DISCORD_DESKTOP_NAMES
+
+
+def is_window_focused(hwnd: int) -> bool:
+ """Never paste or auto-send after the user moved to a different window."""
+ return bool(hwnd and user32.IsWindow(hwnd) and get_foreground_window() == hwnd)
+
+
+user32.SendInput.restype = wt.UINT
+user32.SendInput.argtypes = [wt.UINT, ctypes.POINTER(_INPUT), ctypes.c_int]
+
+
+def _send_keys(seq) -> bool:
+ """Whether SendInput accepted every key event (not app delivery proof)."""
arr = (_INPUT * len(seq))()
for i, (vk, flags) in enumerate(seq):
arr[i].type = INPUT_KEYBOARD
arr[i].u.ki = _KEYBDINPUT(vk, 0, flags, 0, 0)
- user32.SendInput(len(seq), arr, ctypes.sizeof(_INPUT))
+ return int(user32.SendInput(len(seq), arr, ctypes.sizeof(_INPUT))) == len(seq)
-def send_ctrl_v() -> None:
- """OS 수준 Ctrl+V 입력 (클라이언트 수정·계정 자동화 아님 — Win+. 패널과 동일 방식)."""
- _send_keys([(VK_CONTROL, 0), (VK_V, 0),
- (VK_V, KEYEVENTF_KEYUP), (VK_CONTROL, KEYEVENTF_KEYUP)])
+def send_ctrl_v() -> bool:
+ """OS-only Ctrl+V; returns False when Windows blocked key injection."""
+ return _send_keys([(VK_CONTROL, 0), (VK_V, 0),
+ (VK_V, KEYEVENTF_KEYUP), (VK_CONTROL, KEYEVENTF_KEYUP)])
-def send_enter() -> None:
- """OS 수준 Enter 입력. 자동 전송 설정이 켜졌을 때만 쓴다 — 붙여넣기와 달리
- 되돌릴 수 없는 동작이라 기본은 꺼져 있다."""
- _send_keys([(VK_RETURN, 0), (VK_RETURN, KEYEVENTF_KEYUP)])
+def send_enter() -> bool:
+ """Opt-in keyboard Enter; never claim success when SendInput fails."""
+ return _send_keys([(VK_RETURN, 0), (VK_RETURN, KEYEVENTF_KEYUP)])
def focus_window(hwnd: int) -> bool:
diff --git a/notro_app/config.py b/notro_app/config.py
index a94629b..5ca9b5b 100644
--- a/notro_app/config.py
+++ b/notro_app/config.py
@@ -13,24 +13,37 @@
from . import APP_NAME
# ===================== 설정 =====================
-LIMIT_MB = 10 # 디스코드 무료 업로드 한도 (MB)
-SAFETY = 0.95 # 안전 마진 (10MB의 95% = 9.5MB 목표)
+LIMIT_MB = 20 # Discord free tier since August 2026 (MB)
+SAFETY = 0.95 # 안전 마진 (20MB의 95% = 19MB 목표)
POLL_INTERVAL = 0.4 # 클립보드 확인 주기 (초)
# ================================================
+def upload_ceiling_bytes(mb: int) -> int:
+ """Convert UI preset to conservative upload ceiling.
+
+ Nitro's up-to-1GB maximum does not specify binary versus decimal
+ units. The 1GB preset uses decimal 1,000,000,000 bytes so Notro
+ does not silently prepare a 1,073,741,824-byte upload that a decimal
+ 1GB receiver could reject. Historic MB presets keep their byte logic.
+ """
+ return 1_000_000_000 if mb == 1024 else mb * 1024 * 1024
+
+
def compute_limit_bytes(mb: int) -> int:
- """업로드 한도(MB)에 안전 마진을 적용한 바이트 한도."""
- return int(mb * 1024 * 1024 * SAFETY)
+ """Encoding target includes a five-percent headroom below upload cap."""
+ return int(upload_ceiling_bytes(mb) * SAFETY)
+UPLOAD_CEILING_BYTES = upload_ceiling_bytes(LIMIT_MB)
LIMIT_BYTES = compute_limit_bytes(LIMIT_MB)
def set_limit_mb(mb: int) -> None:
"""런타임 업로드 한도 변경 (트레이 메뉴에서 호출)."""
- global LIMIT_MB, LIMIT_BYTES
+ global LIMIT_MB, LIMIT_BYTES, UPLOAD_CEILING_BYTES
LIMIT_MB = mb
+ UPLOAD_CEILING_BYTES = upload_ceiling_bytes(mb)
LIMIT_BYTES = compute_limit_bytes(mb)
diff --git a/notro_app/gif_optimizer.py b/notro_app/gif_optimizer.py
new file mode 100644
index 0000000..459cca8
--- /dev/null
+++ b/notro_app/gif_optimizer.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+"""Opt-in, size-bounded GIF optimization for Notro's preview/paste flow.
+
+Never rewrites source/library files or runs unattended. The caller must show
+an animated side-by-side preview and wait for explicit user acceptance before
+the optimized file is placed on the clipboard.
+"""
+from __future__ import annotations
+
+import os
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+from PIL import Image, ImageSequence, UnidentifiedImageError
+
+from .resize import save_gif
+
+MAX_SOURCE_BYTES = 60 * 1024 * 1024
+MAX_FRAMES = 180
+MAX_TOTAL_PIXELS = 25_000_000
+MAX_DIMENSION = 4096
+
+# Conservative first: color reduction at source dimensions before downscaling.
+# The preview clearly discloses both palette and resolution changes.
+_TRIALS = (
+ (1.00, 128), (1.00, 64), (0.85, 128), (0.85, 64),
+ (0.70, 64), (0.60, 48), (0.50, 32), (0.40, 24),
+)
+
+
+class GIFOptimizationError(ValueError):
+ """Safe, user-displayable category; underlying paths stay private."""
+
+ def __init__(self, code: str):
+ self.code = code
+ super().__init__(code)
+
+
+@dataclass(frozen=True)
+class GIFCandidate:
+ path: str
+ original_bytes: int
+ optimized_bytes: int
+ original_size: tuple[int, int]
+ optimized_size: tuple[int, int]
+ frames: int
+ duration_ms: int
+ colors: int
+ scale: float
+
+
+def optimize_gif(source: str, output_dir: str, target_bytes: int) -> GIFCandidate:
+ """Return a *new* GIF below the encoding target, or fail without mutation.
+
+ The whole animation is decoded only for bounded inputs to avoid pathological
+ frame counts / decompression bombs. Each candidate is animated, maintains
+ the sum of frame delays and preserves the source loop policy.
+ """
+ try:
+ stat = os.stat(source)
+ except OSError as exc:
+ raise GIFOptimizationError("missing") from exc
+ if not source.lower().endswith(".gif"):
+ raise GIFOptimizationError("unsupported")
+ if stat.st_size <= target_bytes:
+ raise GIFOptimizationError("already_fits")
+ if stat.st_size > MAX_SOURCE_BYTES or target_bytes <= 0:
+ raise GIFOptimizationError("too_complex")
+
+ frames: list[Image.Image] = []
+ durations: list[int] = []
+ try:
+ with Image.open(source) as im:
+ if im.format != "GIF" or getattr(im, "n_frames", 1) < 2:
+ raise GIFOptimizationError("not_animated")
+ count = im.n_frames
+ w, h = im.size
+ if (count > MAX_FRAMES or w > MAX_DIMENSION or h > MAX_DIMENSION
+ or w * h * count > MAX_TOTAL_PIXELS):
+ raise GIFOptimizationError("too_complex")
+ loop = im.info.get("loop", 0)
+ loop = int(loop) if isinstance(loop, int) and 0 <= loop <= 65535 else 0
+ for frame in ImageSequence.Iterator(im):
+ frames.append(frame.convert("RGBA").copy())
+ durations.append(max(20, int(frame.info.get("duration", 50) or 50)))
+ except (OSError, UnidentifiedImageError, ValueError, MemoryError) as exc:
+ if isinstance(exc, GIFOptimizationError):
+ raise
+ raise GIFOptimizationError("invalid") from exc
+
+ if len(frames) < 2:
+ raise GIFOptimizationError("not_animated")
+
+ os.makedirs(output_dir, exist_ok=True)
+ candidates: list[str] = []
+ selected: GIFCandidate | None = None
+ success = False
+ try:
+ for scale, colors in _TRIALS:
+ size = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
+ if size[0] < 24 or size[1] < 24:
+ break # never aggressively shrink to an unusable thumbnail
+ fd, path = tempfile.mkstemp(
+ prefix="notro_gif_preview_", suffix=".gif", dir=output_dir)
+ os.close(fd)
+ candidates.append(path)
+ if scale == 1.0:
+ resized = frames
+ else:
+ resized = [f.resize(size, Image.Resampling.LANCZOS) for f in frames]
+ save_gif(resized, durations, path, colors=colors,
+ loop=loop, optimize=True)
+ actual_bytes = os.path.getsize(path)
+ if actual_bytes >= stat.st_size or actual_bytes > target_bytes:
+ continue
+ with Image.open(path) as rendered:
+ if not getattr(rendered, "is_animated", False):
+ continue
+ if (rendered.width != size[0] or rendered.height != size[1]
+ or rendered.n_frames < 2):
+ continue
+ rendered_duration = sum(
+ max(20, int(frame.info.get("duration", 50) or 50))
+ for frame in ImageSequence.Iterator(rendered))
+ if abs(rendered_duration - sum(durations)) > 20 * len(durations):
+ continue
+ selected = GIFCandidate(
+ path=path, original_bytes=stat.st_size,
+ optimized_bytes=actual_bytes, original_size=(w, h),
+ optimized_size=size, frames=count,
+ duration_ms=sum(durations), colors=colors, scale=scale,
+ )
+ break
+ if selected is None:
+ raise GIFOptimizationError("cannot_fit")
+ after = os.stat(source)
+ if after.st_size != stat.st_size or after.st_mtime_ns != stat.st_mtime_ns:
+ raise GIFOptimizationError("source_changed")
+ success = True
+ return selected
+ except GIFOptimizationError:
+ raise
+ except (OSError, MemoryError, ValueError) as exc:
+ raise GIFOptimizationError("encode") from exc
+ finally:
+ for path in candidates:
+ if not success or selected is None or path != selected.path:
+ try:
+ Path(path).unlink(missing_ok=True)
+ except OSError:
+ pass
diff --git a/notro_app/i18n.py b/notro_app/i18n.py
index a45b072..963c9df 100644
--- a/notro_app/i18n.py
+++ b/notro_app/i18n.py
@@ -764,3 +764,395 @@ def tr(key: str, **kwargs) -> str:
table = STRINGS.get(current_lang, STRINGS["en"])
text = table.get(key) or STRINGS["en"].get(key, key)
return text.format(**kwargs) if kwargs else text
+
+
+# Portable packs (opt-in only; exclude private Captures and external paths).
+_PACK_STRINGS = {
+ "en": {"picker_pack_import": "Import pack", "picker_pack_export": "Export pack",
+ "picker_pack_done": "Exported {count} items", "picker_pack_imported": "Imported {added}, already saved {duplicate}, failed {failed}",
+ "picker_pack_error": "Unable to read or write pack; check ZIP validity and storage"},
+ "ko": {"picker_pack_import": "팩 가져오기", "picker_pack_export": "팩 내보내기",
+ "picker_pack_done": "{count}개 항목 내보내기 완료", "picker_pack_imported": "가져옴 {added}개 · 중복 {duplicate}개 · 실패 {failed}개",
+ "picker_pack_error": "팩 처리 실패: ZIP 파일과 저장 공간을 확인하세요"},
+ "ja": {"picker_pack_import": "パックを読み込む", "picker_pack_export": "パックを書き出す",
+ "picker_pack_done": "{count} 件を書き出しました", "picker_pack_imported": "追加 {added} · 重複 {duplicate} · 失敗 {failed}",
+ "picker_pack_error": "パックの処理に失敗しました。ZIP と保存先を確認してください"},
+ "zh": {"picker_pack_import": "导入资源包", "picker_pack_export": "导出资源包",
+ "picker_pack_done": "已导出 {count} 项", "picker_pack_imported": "导入 {added} · 重复 {duplicate} · 失败 {failed}",
+ "picker_pack_error": "资源包处理失败,请检查 ZIP 和存储空间"},
+ "es": {"picker_pack_import": "Importar paquete", "picker_pack_export": "Exportar paquete",
+ "picker_pack_done": "Se exportaron {count} elementos", "picker_pack_imported": "Importados {added} · duplicados {duplicate} · fallidos {failed}",
+ "picker_pack_error": "No se pudo procesar el paquete ZIP; comprueba el archivo y el espacio disponible"},
+}
+for _lang, _table in _PACK_STRINGS.items():
+ STRINGS[_lang].update(_table)
+del _PACK_STRINGS
+
+
+_WELCOME_ACTIONS = {
+ "en": "Try the emoji picker now",
+ "ko": "이모지 선택기 바로 체험하기",
+ "ja": "絵文字ピッカーを試す",
+ "zh": "立即体验表情选择器",
+ "es": "Probar el selector de emojis",
+}
+for _lang, _message in _WELCOME_ACTIONS.items():
+ STRINGS[_lang]["welcome_try_picker"] = _message
+del _WELCOME_ACTIONS
+
+# Optional auto-send is deliberately restricted to a still-focused Discord
+# desktop window. The picker remains a general Windows attachment picker.
+_AUTOSEND_SAFETY_MESSAGES = {
+ "en": (
+ "Auto-send skipped: only available in Discord desktop. Item was pasted; send manually.",
+ "Auto-send canceled: the active window changed after pasting.",
+ "Auto-send works only while the Discord desktop window stays focused. Pasting elsewhere is still supported.",
+ ),
+ "ko": (
+ "자동 전송은 Discord 데스크톱에서만 됩니다. 붙여넣기는 완료했으니 직접 전송하세요.",
+ "붙여넣은 뒤 활성 창이 바뀌어 자동 전송을 취소했습니다.",
+ "Discord 데스크톱 창에 계속 포커스가 있을 때만 자동 전송합니다. 다른 앱에서는 붙여넣기만 합니다.",
+ ),
+ "ja": (
+ "自動送信はDiscordデスクトップ限定です。貼り付け後、手動で送信してください。",
+ "貼り付け後にアクティブウィンドウが変わったため、自動送信を中止しました。",
+ "Discordデスクトップがフォーカスされている間だけ自動送信します。他のアプリでは貼り付けのみです。",
+ ),
+ "zh": (
+ "自动发送仅支持Discord桌面版。内容已粘贴,请手动发送。",
+ "粘贴后活动窗口发生变化,已取消自动发送。",
+ "仅在Discord桌面版窗口持续处于焦点时自动发送。其他应用只执行粘贴。",
+ ),
+ "es": (
+ "El envío automático solo funciona en Discord de escritorio. Se pegó el archivo; envíalo manualmente.",
+ "Se canceló el envío automático porque cambió la ventana activa.",
+ "Solo se envía automáticamente mientras Discord de escritorio mantiene el foco. En otras aplicaciones solo se pega.",
+ ),
+}
+for _lang, (_non_discord, _focus_changed, _note) in _AUTOSEND_SAFETY_MESSAGES.items():
+ STRINGS[_lang]["notify_auto_send_discord_only"] = _non_discord
+ STRINGS[_lang]["notify_auto_send_focus_changed"] = _focus_changed
+ STRINGS[_lang]["picker_auto_send_note"] = _note
+del _AUTOSEND_SAFETY_MESSAGES
+# Explicit opt-in animated GIF optimization (source library files never modified).
+_GIF_PREVIEW_STRINGS = {
+ "en": {
+ "picker_gif_optimize": "Optimize GIF…",
+ "picker_gif_title": "GIF size and quality preview",
+ "picker_gif_loading": "Preparing a comparison…",
+ "picker_gif_original": "Original",
+ "picker_gif_result": "Optimized candidate",
+ "picker_gif_accept": "Paste optimized (send manually)",
+ "picker_gif_cancel": "Cancel",
+ "picker_gif_note": "Compare both animations. Colors or resolution may change. Your original file is never modified; sending the optimized attachment is manual.",
+ "picker_gif_fits": "This GIF already fits the configured size target.",
+ "picker_gif_cannot_fit": "Could not fit this GIF without excessive quality loss. Try a shorter animation or higher upload limit.",
+ "picker_gif_too_complex": "This GIF is too large or complex to optimize safely.",
+ "picker_gif_failed": "Could not produce a safe animated preview.",
+ "picker_gif_outdated": "The GIF or upload limit changed. Reopen the preview.",
+ "picker_gif_info": "frames · send manually",
+ },
+ "ko": {
+ "picker_gif_optimize": "GIF 용량 최적화…",
+ "picker_gif_title": "GIF 용량·화질 비교",
+ "picker_gif_loading": "원본과 최적화 후보를 준비하고 있어요…",
+ "picker_gif_original": "원본",
+ "picker_gif_result": "최적화 후보",
+ "picker_gif_accept": "최적화본 붙여넣기 (직접 전송)",
+ "picker_gif_cancel": "취소",
+ "picker_gif_note": "움직임을 비교하세요. 색상·해상도가 변할 수 있습니다. 원본은 수정하지 않으며, 붙여넣은 뒤 직접 전송해야 합니다.",
+ "picker_gif_fits": "이 GIF는 현재 설정한 용량 목표 안에 들어갑니다.",
+ "picker_gif_cannot_fit": "과도한 화질 저하 없이 용량 목표에 맞추지 못했습니다. 짧은 GIF나 더 높은 한도를 사용해 보세요.",
+ "picker_gif_too_complex": "안전하게 처리하기에는 GIF가 너무 크거나 복잡합니다.",
+ "picker_gif_failed": "GIF 최적화 미리보기를 만들지 못했습니다.",
+ "picker_gif_outdated": "원본 또는 용량 한도가 바뀌었습니다. 미리보기를 다시 열어 주세요.",
+ "picker_gif_info": "프레임 · 전송은 수동",
+ },
+ "ja": {
+ "picker_gif_optimize": "GIFを最適化…",
+ "picker_gif_title": "GIF容量・画質の比較",
+ "picker_gif_loading": "比較用アニメーションを準備中…",
+ "picker_gif_original": "元のGIF",
+ "picker_gif_result": "最適化候補",
+ "picker_gif_accept": "最適化版を貼り付け(送信は手動)",
+ "picker_gif_cancel": "キャンセル",
+ "picker_gif_note": "両方の動きを比較してください。色や解像度が変わる場合があります。元のファイルは変更せず、送信は手動です。",
+ "picker_gif_fits": "このGIFは現在のサイズ目標に収まっています。",
+ "picker_gif_cannot_fit": "画質を保ちながらサイズを収められません。短いアニメーションや高い上限をお試しください。",
+ "picker_gif_too_complex": "このGIFは安全に処理するには大きすぎるか複雑です。",
+ "picker_gif_failed": "GIFの最適化プレビューを生成できませんでした。",
+ "picker_gif_outdated": "元のGIFまたは容量上限が変更されました。再度プレビューしてください。",
+ "picker_gif_info": "フレーム · 送信は手動",
+ },
+ "zh": {
+ "picker_gif_optimize": "优化 GIF…",
+ "picker_gif_title": "GIF 大小和画质对比",
+ "picker_gif_loading": "正在准备对比动画…",
+ "picker_gif_original": "原始 GIF",
+ "picker_gif_result": "优化候选",
+ "picker_gif_accept": "粘贴优化版(手动发送)",
+ "picker_gif_cancel": "取消",
+ "picker_gif_note": "请对比两段动画。颜色或分辨率可能改变。原文件不会被修改;发送需手动完成。",
+ "picker_gif_fits": "此 GIF 已符合当前大小目标。",
+ "picker_gif_cannot_fit": "无法在保持可接受画质的情况下压缩到目标大小。请尝试较短的 GIF 或更高的限额。",
+ "picker_gif_too_complex": "此 GIF 过大或过于复杂,无法安全优化。",
+ "picker_gif_failed": "无法生成安全的 GIF 优化预览。",
+ "picker_gif_outdated": "GIF 原文件或大小限制已更改。请重新预览。",
+ "picker_gif_info": "帧 · 手动发送",
+ },
+ "es": {
+ "picker_gif_optimize": "Optimizar GIF…",
+ "picker_gif_title": "Comparar tamaño y calidad del GIF",
+ "picker_gif_loading": "Preparando animaciones para comparar…",
+ "picker_gif_original": "Original",
+ "picker_gif_result": "Candidato optimizado",
+ "picker_gif_accept": "Pegar optimizado (enviar manualmente)",
+ "picker_gif_cancel": "Cancelar",
+ "picker_gif_note": "Compara ambas animaciones: pueden cambiar los colores o la resolución. El original no se modifica y debes enviar el adjunto manualmente.",
+ "picker_gif_fits": "Este GIF ya cabe en el tamaño configurado.",
+ "picker_gif_cannot_fit": "No cabe sin perder demasiada calidad. Prueba una animación más corta o un límite mayor.",
+ "picker_gif_too_complex": "El GIF es demasiado grande o complejo para optimizarlo de forma segura.",
+ "picker_gif_failed": "No se pudo generar una vista previa optimizada del GIF.",
+ "picker_gif_outdated": "El GIF o el límite de tamaño ha cambiado. Abre otra vista previa.",
+ "picker_gif_info": "fotogramas · envío manual",
+ },
+}
+for _lang, _table in _GIF_PREVIEW_STRINGS.items():
+ STRINGS[_lang].update(_table)
+del _GIF_PREVIEW_STRINGS
+# Explicit settings backup UI. Never ship user image data or tokens in this JSON.
+_PROFILE_BACKUP_STRINGS = {
+ "en": {
+ "picker_profile_export": "Export settings",
+ "picker_profile_import": "Restore settings…",
+ "picker_profile_title": "Preferences backup (.json, no media)",
+ "picker_profile_include_paths": "Include watched-folder paths (private local addresses; unchecked by default)",
+ "picker_profile_no_paths": "Watched paths are excluded by default.",
+ "picker_profile_review_title": "Review settings restoration",
+ "picker_profile_review_note": "Nothing has changed yet. Restore applies preferences only; no images or private Captures. Auto-send and auto-capture will remain OFF, startup is unchanged. Restart Notro for hotkey/language changes.",
+ "picker_profile_folders_restore": "Also add existing watched folders from this backup (optional; never deletes current folders)",
+ "picker_profile_confirm": "Apply reviewed settings",
+ "picker_profile_cancel": "Cancel",
+ "picker_profile_exported": "Saved {settings_count} preferences, {folders_count} watched-folder paths.",
+ "picker_profile_restored": "Applied {applied} preferences; added {folders_added} folders ({folders_missing} missing). Restart Notro.",
+ "picker_profile_error": "Settings backup could not be read or restored. No unapproved settings were applied.",
+ "picker_profile_restart": "Restart Notro to apply hotkey/language changes.",
+ "picker_profile_counts": "Preferences: {settings} · Watched folder paths in file: {folders}",
+ },
+ "ko": {
+ "picker_profile_export": "설정 백업",
+ "picker_profile_import": "설정 복원…",
+ "picker_profile_title": "환경설정 백업 (.json, 이미지 제외)",
+ "picker_profile_include_paths": "감시 폴더 경로 포함 (개인 PC 경로가 노출될 수 있음, 기본 해제)",
+ "picker_profile_no_paths": "감시 폴더 경로는 기본적으로 제외됩니다.",
+ "picker_profile_review_title": "설정 복원 확인",
+ "picker_profile_review_note": "아직 아무것도 변경하지 않았습니다. 이미지·개인 캡처는 복원 대상이 아닙니다. 자동 전송·자동 캡처는 꺼지고 시작 프로그램 설정은 유지됩니다. 단축키·언어 적용 시 Notro를 다시 시작하세요.",
+ "picker_profile_folders_restore": "백업에 있는 기존 감시 폴더 경로도 추가 (선택, 기존 폴더 삭제 없음)",
+ "picker_profile_confirm": "확인한 설정 적용",
+ "picker_profile_cancel": "취소",
+ "picker_profile_exported": "설정 {settings_count}개, 감시 폴더 경로 {folders_count}개 백업 완료.",
+ "picker_profile_restored": "설정 {applied}개 적용 · 폴더 {folders_added}개 추가({folders_missing}개 없음). Notro를 다시 시작하세요.",
+ "picker_profile_error": "설정 백업을 읽거나 복원할 수 없습니다. 승인하지 않은 설정은 적용하지 않았습니다.",
+ "picker_profile_restart": "단축키·언어 적용을 위해 Notro를 다시 시작하세요.",
+ "picker_profile_counts": "설정 {settings}개 · 백업에 포함된 감시 폴더 경로 {folders}개",
+ },
+ "ja": {
+ "picker_profile_export": "設定をバックアップ",
+ "picker_profile_import": "設定を復元…",
+ "picker_profile_title": "設定バックアップ(JSON・画像なし)",
+ "picker_profile_include_paths": "監視フォルダーのパスを含める(個人のローカルパス、初期値はオフ)",
+ "picker_profile_no_paths": "監視フォルダーのパスは初期状態では除外します。",
+ "picker_profile_review_title": "設定の復元を確認",
+ "picker_profile_review_note": "まだ何も変更していません。画像や個人のキャプチャは含みません。自動送信と自動保存はオフのまま、起動設定は変更しません。ショートカットや言語の反映には再起動が必要です。",
+ "picker_profile_folders_restore": "存在する監視フォルダーも追加(任意、既存の設定は削除しません)",
+ "picker_profile_confirm": "確認した設定を適用",
+ "picker_profile_cancel": "キャンセル",
+ "picker_profile_exported": "設定 {settings_count} 件、監視フォルダー {folders_count} 件を保存。",
+ "picker_profile_restored": "設定 {applied} 件を適用、フォルダー {folders_added} 件追加(未検出 {folders_missing} 件)。Notro を再起動してください。",
+ "picker_profile_error": "設定バックアップを読み込めませんでした。未承認の変更は適用されていません。",
+ "picker_profile_restart": "ショートカット・言語を反映するには Notro を再起動してください。",
+ "picker_profile_counts": "設定 {settings} 件 · 保存済み監視フォルダー {folders} 件",
+ },
+ "zh": {
+ "picker_profile_export": "备份设置",
+ "picker_profile_import": "恢复设置…",
+ "picker_profile_title": "偏好设置备份(JSON,不含图片)",
+ "picker_profile_include_paths": "包含监视文件夹路径(可能泄露本地私人路径,默认不选)",
+ "picker_profile_no_paths": "默认不包含监视文件夹路径。",
+ "picker_profile_review_title": "确认恢复设置",
+ "picker_profile_review_note": "尚未修改任何设置。本备份不含媒体或私人截图。自动发送和自动截图保存将保持关闭,开机启动不变。快捷键和语言更改需重启 Notro。",
+ "picker_profile_folders_restore": "同时添加备份中存在的监视文件夹(可选,不删除现有文件夹)",
+ "picker_profile_confirm": "应用已检查的设置",
+ "picker_profile_cancel": "取消",
+ "picker_profile_exported": "已保存 {settings_count} 项设置及 {folders_count} 个监视路径。",
+ "picker_profile_restored": "已应用 {applied} 项设置,新增 {folders_added} 个文件夹(缺失 {folders_missing} 个)。请重启 Notro。",
+ "picker_profile_error": "无法读取或恢复设置备份。未执行未经批准的更改。",
+ "picker_profile_restart": "请重启 Notro 以应用快捷键和语言。",
+ "picker_profile_counts": "设置 {settings} 项 · 备份中的监视路径 {folders} 个",
+ },
+ "es": {
+ "picker_profile_export": "Exportar ajustes",
+ "picker_profile_import": "Restaurar ajustes…",
+ "picker_profile_title": "Copia de preferencias (JSON, sin archivos multimedia)",
+ "picker_profile_include_paths": "Incluir rutas de carpetas vigiladas (rutas privadas; desactivado por defecto)",
+ "picker_profile_no_paths": "Las rutas de carpetas se excluyen por defecto.",
+ "picker_profile_review_title": "Revisar restauración",
+ "picker_profile_review_note": "Todavía no se ha cambiado nada. No incluye imágenes ni capturas privadas. El envío y la captura automáticos quedarán desactivados; el inicio de Windows no cambia. Reinicia Notro para el idioma y atajos.",
+ "picker_profile_folders_restore": "Agregar las carpetas vigiladas existentes del archivo (opcional, sin borrar las actuales)",
+ "picker_profile_confirm": "Aplicar ajustes revisados",
+ "picker_profile_cancel": "Cancelar",
+ "picker_profile_exported": "Guardados {settings_count} ajustes y {folders_count} rutas de carpetas.",
+ "picker_profile_restored": "Aplicados {applied} ajustes y añadidas {folders_added} carpetas ({folders_missing} ausentes). Reinicia Notro.",
+ "picker_profile_error": "No se pudo leer o restaurar la copia de ajustes. No se aplicaron cambios no aprobados.",
+ "picker_profile_restart": "Reinicia Notro para aplicar los atajos y el idioma.",
+ "picker_profile_counts": "Ajustes: {settings} · Rutas vigiladas guardadas: {folders}",
+ },
+}
+for _profile_lang, _profile_strings in _PROFILE_BACKUP_STRINGS.items():
+ STRINGS[_profile_lang].update(_profile_strings)
+del _PROFILE_BACKUP_STRINGS
+
+# Safe paste messages — all five UI locales, distinct from "clipboard ready".
+_FOCUS_CLIPBOARD_GUARDS = {
+ "en": (
+ "Original target window is no longer focused. Clipboard was not changed.",
+ "A newer clipboard copy was detected. Notro kept your new copy.",
+ ),
+ "ko": (
+ "원래 붙여넣을 창이 활성화되지 않았습니다. 클립보드는 변경하지 않았습니다.",
+ "새로 복사한 내용이 감지되어 해당 클립보드 내용을 보존했습니다.",
+ ),
+ "ja": (
+ "元の貼り付け先がフォーカスされていません。クリップボードは変更しませんでした。",
+ "新しいコピーが見つかりました。新しいクリップボード内容を保持しました。",
+ ),
+ "zh": (
+ "原来的粘贴窗口未获得焦点,剪贴板没有被修改。",
+ "检测到更新的复制内容,已保留新的剪贴板内容。",
+ ),
+ "es": (
+ "La ventana de destino ya no tiene el foco. No se modificó el portapapeles.",
+ "Se detectó una copia posterior; se conservó ese contenido del portapapeles.",
+ ),
+}
+for _locale, (_target_lost, _newer_copy) in _FOCUS_CLIPBOARD_GUARDS.items():
+ STRINGS[_locale]["notify_paste_target_lost"] = _target_lost
+ STRINGS[_locale]["notify_clipboard_changed"] = _newer_copy
+del _FOCUS_CLIPBOARD_GUARDS
+# The independent Windows tray popup has its own localized presentation text.
+# The product is NOT an official Discord add-on and remains unsigned by choice.
+_TRAY_PANEL_TRANSLATIONS = {
+ "en": {
+ "tray_about": "About Notro",
+ "tray_settings": "Settings",
+ "tray_back": "Back",
+ "tray_quick": "QUICK ACTIONS",
+ "tray_running": "ACTIVE",
+ "tray_paused": "PAUSED",
+ "tray_picker_note": "Your personal emoji, sticker and GIF collection",
+ "tray_footnote": "Windows media utility · Independent from Discord",
+ "tray_limit_note": "Free 20 MB · Nitro Basic 50 MB · Nitro up to 1 GB; actual limits vary",
+ "tray_about_note": "Your media collection, one hotkey away.",
+ "tray_platform": "Supported OS",
+ "tray_engine": "Display engine",
+ "tray_license": "License",
+ "tray_disclosure": "Unsigned Windows utility. Independent from and not endorsed by Discord. Files are pasted as attachments, not native Nitro emoji.",
+ "tray_links": "PROJECT LINKS",
+ "tray_github": "GitHub repository",
+ "tray_releases": "Releases & downloads",
+ "tray_issues": "Report an issue",
+ "tray_license_link": "MIT license",
+ "tray_failed": "Couldn't complete that action. You can use the classic tray menu.",
+ },
+ "ko": {
+ "tray_about": "Notro 정보",
+ "tray_settings": "설정",
+ "tray_back": "뒤로",
+ "tray_quick": "빠른 작업",
+ "tray_running": "실행 중",
+ "tray_paused": "일시 정지",
+ "tray_picker_note": "내 이모지·스티커·GIF를 빠르게 찾아보세요",
+ "tray_footnote": "Windows 미디어 도구 · Discord와 독립적으로 작동",
+ "tray_limit_note": "무료 20MB · Nitro Basic 50MB · Nitro 최대 1GB (계정별 상이)",
+ "tray_about_note": "내 미디어 라이브러리를 단축키 하나로.",
+ "tray_platform": "지원 운영체제",
+ "tray_engine": "화면 엔진",
+ "tray_license": "라이선스",
+ "tray_disclosure": "서명되지 않은 Windows 유틸리티입니다. Discord의 공식 제품이 아니며, 미디어는 Nitro 이모지가 아닌 파일 첨부로 붙여넣습니다.",
+ "tray_links": "프로젝트 링크",
+ "tray_github": "GitHub 저장소",
+ "tray_releases": "릴리스 및 다운로드",
+ "tray_issues": "문제 제보",
+ "tray_license_link": "MIT 라이선스",
+ "tray_failed": "작업을 완료하지 못했습니다. 기본 트레이 메뉴를 이용할 수 있습니다.",
+ },
+ "ja": {
+ "tray_about": "Notro について",
+ "tray_settings": "設定",
+ "tray_back": "戻る",
+ "tray_quick": "クイック操作",
+ "tray_running": "動作中",
+ "tray_paused": "一時停止",
+ "tray_picker_note": "自分の絵文字・ステッカー・GIFを簡単検索",
+ "tray_footnote": "独立した Windows メディアツール",
+ "tray_limit_note": "無料 20MB · Nitro Basic 50MB · Nitro 最大 1GB(アカウントにより異なる)",
+ "tray_about_note": "自分のメディアをショートカットひとつで。",
+ "tray_platform": "対応 OS",
+ "tray_engine": "表示エンジン",
+ "tray_license": "ライセンス",
+ "tray_disclosure": "未署名の Windows ユーティリティ。Discord 非公式で、画像は Nitro 絵文字ではなく添付ファイルになります。",
+ "tray_links": "プロジェクトリンク",
+ "tray_github": "GitHub リポジトリ",
+ "tray_releases": "リリースとダウンロード",
+ "tray_issues": "不具合を報告",
+ "tray_license_link": "MIT ライセンス",
+ "tray_failed": "操作に失敗しました。Windows の従来メニューを使えます。",
+ },
+ "zh": {
+ "tray_about": "关于 Notro",
+ "tray_settings": "设置",
+ "tray_back": "返回",
+ "tray_quick": "快捷操作",
+ "tray_running": "运行中",
+ "tray_paused": "已暂停",
+ "tray_picker_note": "快速搜索个人表情、贴纸和 GIF",
+ "tray_footnote": "独立于 Discord 的 Windows 媒体工具",
+ "tray_limit_note": "免费 20MB · Nitro Basic 50MB · Nitro 最多 1GB(因账号而异)",
+ "tray_about_note": "一个快捷键,打开个人媒体收藏。",
+ "tray_platform": "支持系统",
+ "tray_engine": "显示引擎",
+ "tray_license": "许可协议",
+ "tray_disclosure": "未签名的 Windows 工具,与 Discord 无官方关联。媒体以文件附件形式粘贴,并非原生 Nitro 表情。",
+ "tray_links": "项目链接",
+ "tray_github": "GitHub 仓库",
+ "tray_releases": "版本与下载",
+ "tray_issues": "报告问题",
+ "tray_license_link": "MIT 许可证",
+ "tray_failed": "操作失败,可使用 Windows 原生托盘菜单。",
+ },
+ "es": {
+ "tray_about": "Acerca de Notro",
+ "tray_settings": "Ajustes",
+ "tray_back": "Volver",
+ "tray_quick": "ACCIONES RÁPIDAS",
+ "tray_running": "ACTIVO",
+ "tray_paused": "PAUSADO",
+ "tray_picker_note": "Tu colección de emojis, stickers y GIFs",
+ "tray_footnote": "Utilidad multimedia de Windows independiente de Discord",
+ "tray_limit_note": "Gratis 20 MB · Nitro Basic 50 MB · Nitro hasta 1 GB (según cuenta)",
+ "tray_about_note": "Tu colección multimedia a un atajo de distancia.",
+ "tray_platform": "Sistema compatible",
+ "tray_engine": "Motor visual",
+ "tray_license": "Licencia",
+ "tray_disclosure": "Herramienta de Windows sin firma y sin afiliación con Discord. Los archivos se pegan como adjuntos, no como emojis nativos de Nitro.",
+ "tray_links": "ENLACES DEL PROYECTO",
+ "tray_github": "Repositorio GitHub",
+ "tray_releases": "Versiones y descargas",
+ "tray_issues": "Informar de un problema",
+ "tray_license_link": "Licencia MIT",
+ "tray_failed": "No se pudo completar. Puedes usar el menú clásico de Windows.",
+ },
+}
+for _code, _values in _TRAY_PANEL_TRANSLATIONS.items():
+ STRINGS[_code].update(_values)
+del _TRAY_PANEL_TRANSLATIONS
diff --git a/notro_app/library.py b/notro_app/library.py
index 311fcb9..ee491c6 100644
--- a/notro_app/library.py
+++ b/notro_app/library.py
@@ -15,7 +15,8 @@
import time
import uuid
-SUPPORTED_EXTS = (".png", ".gif", ".webp")
+# Match importer extensions: watched JPEG files must not disappear from the picker.
+SUPPORTED_EXTS = (".png", ".gif", ".webp", ".jpg", ".jpeg")
SCHEMA_VERSION = 1
_INVALID_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
@@ -100,7 +101,14 @@ def _slug(name) -> str:
name = (name or "").strip()
if not name:
return "_uncategorized"
- return _INVALID_CHARS.sub("_", name)[:80] or "_uncategorized"
+ slug = _INVALID_CHARS.sub("_", name)[:80].strip(" .")
+ # On Windows, "." and ".." resolve to ancestor paths and DOS device names
+ # are not valid regular directories. Untrusted pack names must never escape.
+ reserved = {"CON", "PRN", "AUX", "NUL", *(f"COM{n}" for n in range(1, 10)),
+ *(f"LPT{n}" for n in range(1, 10))}
+ if not slug or slug.upper() in reserved:
+ return "_uncategorized"
+ return slug
class Library:
@@ -135,7 +143,8 @@ def _load(self) -> None:
try:
with open(path, "r", encoding="utf-8") as f:
apply(json.load(f))
- except (json.JSONDecodeError, OSError, KeyError, TypeError):
+ except (json.JSONDecodeError, OSError, KeyError, TypeError,
+ ValueError, AttributeError):
try: # 손상 파일은 백업하고 빈 상태로 시작
os.replace(path, path + ".bak")
except OSError:
@@ -159,8 +168,22 @@ def _migrate_flat_assets_to_folders(self) -> None:
pass
def _apply_lib(self, data: dict) -> None:
+ if not isinstance(data, dict) or not isinstance(data.get("items"), list):
+ raise ValueError("invalid library item collection")
items = {}
- for i in data.get("items", []):
+ for i in data["items"]:
+ if not isinstance(i, dict):
+ raise ValueError("invalid library item")
+ ident, filename, kind = i.get("id"), i.get("filename"), i.get("type")
+ if (not isinstance(ident, str) or not ident or len(ident) > 128
+ or not isinstance(kind, str)
+ or kind not in {"emoji", "sticker", "gif"}
+ or not isinstance(filename, str) or not filename
+ or filename in {".", ".."}
+ or "/" in filename or "\\" in filename
+ or filename.endswith((" ", "."))
+ or any(ord(c) < 32 for c in filename)):
+ raise ValueError("invalid stored asset entry")
i.setdefault("convert_warning", False) # 구버전 항목 하위호환
i.setdefault("favorite", False)
i.setdefault("collection", "")
@@ -169,7 +192,19 @@ def _apply_lib(self, data: dict) -> None:
self._items = items
def _apply_folders(self, data: dict) -> None:
- self._folders = list(data.get("folders", []))
+ if not isinstance(data, dict) or not isinstance(data.get("folders"), list):
+ raise ValueError("invalid folder collection")
+ folders = data["folders"]
+ if any(
+ not isinstance(folder, dict)
+ or not isinstance(folder.get("path"), str)
+ or not folder["path"]
+ or not isinstance(folder.get("default_type"), str)
+ or folder["default_type"] not in {"emoji", "sticker", "gif"}
+ for folder in folders
+ ):
+ raise ValueError("invalid stored watched folder")
+ self._folders = list(folders)
def _save(self) -> None:
self._atomic_write(self._lib_path(),
diff --git a/notro_app/monitor.py b/notro_app/monitor.py
index 755e15d..a574990 100644
--- a/notro_app/monitor.py
+++ b/notro_app/monitor.py
@@ -72,7 +72,7 @@ def process_clipboard(self):
if png is not None:
if not cb.clipboard_has_files():
self._emit_capture(png)
- if len(png) <= config.LIMIT_BYTES:
+ if len(png) <= config.UPLOAD_CEILING_BYTES:
return # 실제 PNG가 한도 이하 → 그대로 둠
orig_bytes = len(png)
try:
@@ -113,7 +113,7 @@ def process_clipboard(self):
if ext in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"):
try:
size = os.path.getsize(path)
- if size > config.LIMIT_BYTES:
+ if size > config.UPLOAD_CEILING_BYTES:
orig_bytes = size
img = Image.open(path)
img.load()
@@ -131,7 +131,7 @@ def process_clipboard(self):
except Exception:
pass
orig_bytes = compress.estimate_png_size(img)
- if orig_bytes <= config.LIMIT_BYTES:
+ if orig_bytes <= config.UPLOAD_CEILING_BYTES:
return # 한도 이하 → 그대로 둠
self._compress_and_replace(img, orig_bytes, guard_seq)
diff --git a/notro_app/packs.py b/notro_app/packs.py
new file mode 100644
index 0000000..00e6113
--- /dev/null
+++ b/notro_app/packs.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+"""Portable, explicitly user-initiated Notro emoji/sticker/GIF packs.
+
+No account tokens, Discord URLs, device paths, auto-captured private screenshots,
+or clipboard history are exported. Only user-registered media, metadata and
+content hashes are included. Never extract a caller-controlled zip path.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import tempfile
+import zipfile
+from pathlib import Path
+
+from PIL import Image, UnidentifiedImageError
+
+from . import fetch
+from .capture_store import CAPTURE_COLLECTION_ID
+
+FORMAT = "notro-media-pack"
+VERSION = 1
+MAX_ITEMS = 1500
+MAX_ITEM_SIZE = 20 * 1024 * 1024
+MAX_TOTAL_SIZE = 180 * 1024 * 1024
+# The compressed archive cannot be substantially larger than the max
+# uncompressed payload plus bounded metadata/ZIP overhead.
+MAX_ARCHIVE_BYTES = MAX_TOTAL_SIZE + 16 * 1024 * 1024
+MAX_MANIFEST_SIZE = 2 * 1024 * 1024
+MAX_IMAGE_PIXELS = 40_000_000
+EXTENSIONS = {".png", ".gif", ".webp", ".jpg", ".jpeg"}
+KINDS = {"emoji", "sticker", "gif"}
+PATH_RE = re.compile(r"assets/[0-9]{5}\.(?:png|gif|webp|jpg|jpeg)\Z")
+SHA_RE = re.compile(r"[a-f0-9]{64}\Z")
+FORMATS = {
+ ".png": "PNG", ".gif": "GIF", ".webp": "WEBP",
+ ".jpg": "JPEG", ".jpeg": "JPEG",
+}
+
+
+class PackError(ValueError):
+ """Bad or unsafe pack; no assets are imported until preflight passes."""
+
+
+def _digest_file(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as f:
+ for chunk in iter(lambda: f.read(65536), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _check_meta(item: dict) -> dict:
+ if not isinstance(item, dict):
+ raise PackError("bad item metadata")
+ name = item.get("name")
+ kind = item.get("type")
+ collection = item.get("collection", "")
+ keywords = item.get("keywords", [])
+ if not isinstance(name, str) or not name.strip() or len(name) > 160:
+ raise PackError("bad item name")
+ if not isinstance(kind, str) or kind not in KINDS:
+ raise PackError("bad item kind")
+ if not isinstance(collection, str) or len(collection) > 80:
+ raise PackError("bad collection")
+ # Library._slug("..") would name the parent directory on Windows.
+ if collection.strip().strip(".") == "" and collection.strip():
+ raise PackError("unsafe collection")
+ if not isinstance(keywords, list) or len(keywords) > 32:
+ raise PackError("bad keywords")
+ if not all(isinstance(k, str) and len(k) <= 80 for k in keywords):
+ raise PackError("bad keyword")
+ return {
+ "name": name.strip(), "type": kind, "collection": collection.strip(),
+ "keywords": list(keywords),
+ }
+
+
+def export_pack(library, destination: str) -> dict:
+ """Atomic ZIP export of registered items, excluding private Captures."""
+ dest = Path(destination)
+ if dest.suffix.lower() != ".zip":
+ raise PackError("pack filename must end in .zip")
+ items = [item for item in library.items()
+ if item.get("collection") != CAPTURE_COLLECTION_ID]
+ if len(items) > MAX_ITEMS:
+ raise PackError("too many items")
+ manifest = []
+ payloads = []
+ total = 0
+ for item in items:
+ meta = _check_meta(item)
+ src = Path(library.asset_path(item))
+ ext = src.suffix.lower()
+ if ext not in EXTENSIONS or not src.is_file():
+ raise PackError(f"missing or unsupported asset: {item['name']}")
+ size = src.stat().st_size
+ if size > MAX_ITEM_SIZE or total + size > MAX_TOTAL_SIZE:
+ raise PackError("pack is too large")
+ total += size
+ arc = f"assets/{len(manifest)+1:05d}{ext}"
+ manifest.append({**meta, "file": arc, "sha256": _digest_file(str(src))})
+ payloads.append((src, arc))
+ data = json.dumps({
+ "format": FORMAT, "version": VERSION, "items": manifest,
+ }, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+ if len(data) > MAX_MANIFEST_SIZE:
+ raise PackError("pack manifest too large")
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix="notro-pack-", suffix=".zip", dir=dest.parent)
+ os.close(fd)
+ try:
+ with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as z:
+ z.writestr("manifest.json", data)
+ for src, arc in payloads:
+ z.write(src, arcname=arc)
+ os.replace(tmp, dest)
+ finally:
+ if os.path.exists(tmp):
+ os.unlink(tmp)
+ return {"count": len(manifest), "bytes": total, "path": str(dest)}
+
+
+def import_pack(library, archive: str) -> dict:
+ """Reject malformed archives as PackError without touching the library."""
+ try:
+ return _import_pack_validated(library, archive)
+ except (zipfile.BadZipFile, zipfile.LargeZipFile, EOFError,
+ NotImplementedError, RuntimeError) as exc:
+ raise PackError("corrupt or unsupported ZIP") from exc
+
+
+def _import_pack_validated(library, archive: str) -> dict:
+ """Preflight all ZIP contents before registering user media."""
+ path = Path(archive)
+ if path.suffix.lower() != ".zip" or not path.is_file():
+ raise PackError("not a ZIP media pack")
+ if path.stat().st_size > MAX_ARCHIVE_BYTES:
+ raise PackError("ZIP archive exceeds maximum size")
+ records = []
+ with zipfile.ZipFile(path, "r") as z:
+ infos = z.infolist()
+ names = [info.filename for info in infos]
+ if len(names) != len(set(names)) or len(names) > MAX_ITEMS + 1:
+ raise PackError("invalid ZIP members")
+ if "manifest.json" not in names:
+ raise PackError("manifest is missing")
+ if any(name != "manifest.json" and not PATH_RE.fullmatch(name) for name in names):
+ raise PackError("unrecognized ZIP path")
+ if any(info.flag_bits & 1 for info in infos):
+ raise PackError("encrypted ZIP files are unsupported")
+ by_name = {info.filename: info for info in infos}
+ if by_name["manifest.json"].file_size > MAX_MANIFEST_SIZE:
+ raise PackError("manifest too large")
+ # Never call ZipFile.read() unbounded on untrusted compressed input.
+ # Limit the bytes decompressed even if ZIP metadata is inconsistent.
+ with z.open("manifest.json") as manifest_file:
+ manifest_bytes = manifest_file.read(MAX_MANIFEST_SIZE + 1)
+ if len(manifest_bytes) > MAX_MANIFEST_SIZE:
+ raise PackError("manifest too large")
+ try:
+ manifest = json.loads(manifest_bytes)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise PackError("invalid manifest") from exc
+ if (not isinstance(manifest, dict)
+ or manifest.get("format") != FORMAT
+ or manifest.get("version") != VERSION):
+ raise PackError("unsupported pack format/version")
+ items = manifest.get("items")
+ if not isinstance(items, list) or len(items) > MAX_ITEMS:
+ raise PackError("too many items")
+ declared = set()
+ total = 0
+ for entry in items:
+ meta = _check_meta(entry)
+ filename = entry.get("file")
+ expected = entry.get("sha256")
+ if (not isinstance(filename, str) or not PATH_RE.fullmatch(filename)
+ or filename not in by_name or filename in declared
+ or not isinstance(expected, str) or not SHA_RE.fullmatch(expected)):
+ raise PackError("invalid media entry")
+ declared.add(filename)
+ info = by_name[filename]
+ total += info.file_size
+ if info.file_size > MAX_ITEM_SIZE or total > MAX_TOTAL_SIZE:
+ raise PackError("pack exceeds allowed size")
+ records.append((filename, expected, meta))
+ if set(names) != declared | {"manifest.json"}:
+ raise PackError("unexpected extra media")
+ # All decompression and content checks happen before library writes.
+ with tempfile.TemporaryDirectory(prefix="notro-import-") as scratch:
+ checked = []
+ for n, (filename, expected, meta) in enumerate(records):
+ dest = Path(scratch) / f"asset-{n:05d}{Path(filename).suffix.lower()}"
+ digest = hashlib.sha256()
+ received = 0
+ with z.open(filename) as source, open(dest, "wb") as out:
+ while True:
+ part = source.read(65536)
+ if not part:
+ break
+ received += len(part)
+ if received > MAX_ITEM_SIZE:
+ raise PackError("decompressed asset too large")
+ digest.update(part)
+ out.write(part)
+ if received != by_name[filename].file_size or digest.hexdigest() != expected:
+ raise PackError("asset SHA-256 mismatch")
+ try:
+ with Image.open(dest) as image:
+ if (image.format != FORMATS[dest.suffix.lower()]
+ or image.width * image.height > MAX_IMAGE_PIXELS):
+ raise PackError("invalid/oversized image")
+ image.verify()
+ except (OSError, UnidentifiedImageError) as exc:
+ raise PackError("invalid image content") from exc
+ checked.append((str(dest), meta))
+ added = duplicate = failed = 0
+ for dest, meta in checked:
+ try:
+ fetch.register_from_file(
+ library, dest, meta["type"], name=meta["name"],
+ keywords=meta["keywords"], collection=meta["collection"])
+ added += 1
+ except fetch.DuplicateAssetError:
+ duplicate += 1
+ except Exception:
+ failed += 1
+ return {"added": added, "duplicate": duplicate, "failed": failed}
diff --git a/notro_app/picker/assets_server.py b/notro_app/picker/assets_server.py
index 96c0d4e..d1d078a 100644
--- a/notro_app/picker/assets_server.py
+++ b/notro_app/picker/assets_server.py
@@ -11,6 +11,7 @@
import mimetypes
import os
+import secrets
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -23,40 +24,83 @@ def __init__(self, resolver):
self._resolver = resolver
self._httpd: ThreadingHTTPServer | None = None
self.port = 0
+ # Per-server capability: a random localhost port alone is not an
+ # authentication boundary. The token does not contain library data.
+ self._capability = secrets.token_urlsafe(32)
+ # Temporary GIF previews are in the same private localhost capability
+ # namespace as library items, but never written to the user library.
+ self._temporary: dict[str, str] = {}
+ self._temporary_lock = threading.Lock()
+
+ def register_temporary_asset(self, path: str) -> str:
+ """Expose a generated QA/preview file without changing library.json."""
+ if not os.path.isfile(path):
+ raise FileNotFoundError(path)
+ key = "gif-preview:" + secrets.token_urlsafe(24)
+ with self._temporary_lock:
+ self._temporary[key] = path
+ return key
+
+ def release_temporary_asset(self, item_id: str) -> None:
+ with self._temporary_lock:
+ self._temporary.pop(item_id, None)
def start(self) -> int:
resolver = self._resolver
+ capability = self._capability
+ temporary = self._temporary
+ temporary_lock = self._temporary_lock
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (http.server 규약)
- if not self.path.startswith("/asset/"):
+ # Reject browser DNS-rebinding aliases and forged virtual
+ # hosts. The picker uses the exact 127.0.0.1 URL below.
+ expected_host = f"127.0.0.1:{self.server.server_port}"
+ if self.headers.get("Host", "") != expected_host:
+ self.send_error(403)
+ return
+ prefix = f"/asset/{capability}/"
+ if not self.path.startswith(prefix):
self.send_error(404)
return
- item_id = urllib.parse.unquote(self.path[len("/asset/"):])
+ item_id = urllib.parse.unquote(self.path[len(prefix):])
try:
- path = resolver(item_id)
+ with temporary_lock:
+ path = temporary.get(item_id)
+ if path is None:
+ path = resolver(item_id)
except Exception:
path = None
if not path or not os.path.isfile(path):
self.send_error(404)
return
ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
+ # Open before emitting 200; watched-folder assets can be moved
+ # between resolve() and open(), which must return 404 rather
+ # than a partially written 200 response with invalid headers.
try:
- with open(path, "rb") as f:
- data = f.read()
+ source = open(path, "rb")
except OSError:
self.send_error(404)
return
- self.send_response(200)
- self.send_header("Content-Type", ctype)
- self.send_header("Content-Length", str(len(data)))
- # 감시 폴더의 파일 교체가 즉시 반영되도록 캐시하지 않는다
- self.send_header("Cache-Control", "no-store")
- self.end_headers()
- try:
- self.wfile.write(data)
- except (ConnectionAbortedError, BrokenPipeError):
- pass
+ with source:
+ try:
+ size = os.fstat(source.fileno()).st_size
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(size))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ remaining = size
+ # Keep per-request memory bounded for large GIFs.
+ while remaining > 0:
+ chunk = source.read(min(65536, remaining))
+ if not chunk:
+ break # file was truncated after fstat
+ self.wfile.write(chunk)
+ remaining -= len(chunk)
+ except (ConnectionAbortedError, BrokenPipeError, OSError):
+ pass
def log_message(self, *args): # 콘솔 소음 제거
pass
@@ -67,9 +111,11 @@ def log_message(self, *args): # 콘솔 소음 제거
return self.port
def url_for(self, item_id: str) -> str:
- return f"http://127.0.0.1:{self.port}/asset/{urllib.parse.quote(item_id, safe='')}"
+ return f"http://127.0.0.1:{self.port}/asset/{self._capability}/{urllib.parse.quote(item_id, safe='')}"
def stop(self) -> None:
+ with self._temporary_lock:
+ self._temporary.clear()
if self._httpd:
try:
self._httpd.shutdown()
diff --git a/notro_app/picker/gif_preview.py b/notro_app/picker/gif_preview.py
new file mode 100644
index 0000000..fc6198e
--- /dev/null
+++ b/notro_app/picker/gif_preview.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+"""Opt-in GIF preview bridge. Used by PickerApi; not a Discord integration."""
+from __future__ import annotations
+
+import os
+import threading
+
+from .. import config, gif_optimizer, resize
+
+
+class GifPreviewMixin:
+ """Keep a single candidate in a private temp file until user acts.
+
+ The pywebview API inspects public methods on PickerApi, so these methods
+ intentionally appear there through inheritance. State remains private.
+ """
+
+ def _init_gif_preview(self) -> None:
+ self._gif_lock = threading.Lock()
+ self._gif_preview = None
+
+ def _drop_gif_locked(self, keep_file: bool = False) -> None:
+ preview = self._gif_preview
+ self._gif_preview = None
+ if preview is None:
+ return
+ self._asset_server.release_temporary_asset(preview["token"])
+ if preview.get("original_token"):
+ self._asset_server.release_temporary_asset(preview["original_token"])
+ if not keep_file:
+ try:
+ os.remove(preview["candidate_path"])
+ except OSError:
+ pass
+
+ def discard_gif_preview(self, token: str = "") -> bool:
+ """Cancel a generated candidate without touching the source GIF."""
+ with self._gif_lock:
+ if not self._gif_preview:
+ return False
+ if token and token != self._gif_preview["token"]:
+ return False
+ self._drop_gif_locked()
+ return True
+
+ def preview_gif(self, item_id: str) -> dict:
+ """Prepare a bounded GIF candidate; NEVER paste on preview request."""
+ with self._gif_lock:
+ self._drop_gif_locked()
+ item = self._library.resolve(item_id)
+ if not item or item.get("type") not in ("gif", "sticker", "emoji"):
+ return {"ok": False, "error": "unsupported"}
+ source_path = self._library.asset_path(item)
+ path = resize.normalize(
+ source_path, resize.target_px_for(item["type"]), config.TEMP_DIR)
+ if not path.lower().endswith(".gif"):
+ return {"ok": False, "error": "unsupported"}
+ try:
+ source_stat = os.stat(source_path)
+ prepared_stat = os.stat(path)
+ except OSError:
+ return {"ok": False, "error": "missing"}
+ # This feature is only needed when the animation *actually*
+ # exceeds the configured upload ceiling. The 95% safety target
+ # applies to its output, not the prompt/preview trigger.
+ if prepared_stat.st_size <= config.UPLOAD_CEILING_BYTES:
+ return {"ok": False, "error": "already_fits"}
+ try:
+ result = gif_optimizer.optimize_gif(
+ path, config.TEMP_DIR, config.LIMIT_BYTES)
+ except gif_optimizer.GIFOptimizationError as exc:
+ return {"ok": False, "error": exc.code}
+ original_token = None
+ try:
+ token = self._asset_server.register_temporary_asset(result.path)
+ if path != source_path:
+ # The user sees the *prepared/normalized* source in the
+ # left preview, matching the byte count being compared.
+ original_token = self._asset_server.register_temporary_asset(path)
+ except Exception:
+ if "token" in locals():
+ self._asset_server.release_temporary_asset(token)
+ try:
+ os.remove(result.path)
+ except OSError:
+ pass
+ return {"ok": False, "error": "encode"}
+ self._gif_preview = {
+ "token": token, "original_token": original_token,
+ "item_id": item_id, "source_path": source_path,
+ "source_sig": (source_stat.st_mtime_ns, source_stat.st_size),
+ "prepared_path": path,
+ "prepared_sig": (prepared_stat.st_mtime_ns, prepared_stat.st_size),
+ "candidate_path": result.path,
+ "original_limit_bytes": config.LIMIT_BYTES,
+ "original_upload_ceiling_bytes": config.UPLOAD_CEILING_BYTES,
+ }
+ return {
+ "ok": True, "token": token, "item_id": item_id,
+ "original_url": self._asset_server.url_for(
+ original_token or item_id),
+ "optimized_url": self._asset_server.url_for(token),
+ "original_bytes": result.original_bytes,
+ "optimized_bytes": result.optimized_bytes,
+ "original_size": list(result.original_size),
+ "optimized_size": list(result.optimized_size),
+ "frames": result.frames, "duration_ms": result.duration_ms,
+ "colors": result.colors,
+ }
+
+ def paste_gif_preview(self, item_id: str, token: str) -> dict:
+ """Paste accepted candidate but never auto-send an unreviewed message."""
+ with self._gif_lock:
+ preview = self._gif_preview
+ if not preview or preview["token"] != token or preview["item_id"] != item_id:
+ return {"ok": False, "error": "outdated"}
+ try:
+ stat = os.stat(preview["source_path"])
+ prepared = os.stat(preview["prepared_path"])
+ valid = (
+ (stat.st_mtime_ns, stat.st_size) == preview["source_sig"]
+ and (prepared.st_mtime_ns, prepared.st_size)
+ == preview["prepared_sig"]
+ and config.LIMIT_BYTES == preview["original_limit_bytes"]
+ and config.UPLOAD_CEILING_BYTES ==
+ preview["original_upload_ceiling_bytes"]
+ and os.path.getsize(preview["candidate_path"]) <= config.LIMIT_BYTES
+ and self._library.resolve(item_id) is not None
+ )
+ except OSError:
+ valid = False
+ if not valid:
+ self._drop_gif_locked()
+ return {"ok": False, "error": "outdated"}
+ candidate_path = preview["candidate_path"]
+ self._drop_gif_locked(keep_file=True)
+ if self._ctrl is None:
+ return {"ok": False, "error": "outdated"}
+ pasted = self._ctrl.select(
+ item_id, mode="optimized", override_path=candidate_path)
+ return {"ok": bool(pasted)}
diff --git a/notro_app/picker/ui/app.css b/notro_app/picker/ui/app.css
index 01df2cf..576b6d5 100644
--- a/notro_app/picker/ui/app.css
+++ b/notro_app/picker/ui/app.css
@@ -214,3 +214,22 @@ button.wide:hover { background: var(--accent); color: #fff; }
display: flex; align-items: center; justify-content: center;
color: var(--text); font-weight: 600; pointer-events: none;
}
+
+/* Explicit before/after decision: no silent lossy GIF conversion. */
+.gif-modal { width: 450px; }
+.gif-compare { display:grid; grid-template-columns:1fr 1fr; gap:9px; }
+.gif-preview-panel { min-width:0; display:flex; flex-direction:column; gap:5px; }
+.gif-preview-panel span { font-size:11px; color:var(--muted); }
+.gif-preview-panel img {
+ width:100%; height:125px; background:var(--panel); border:1px solid var(--hover);
+ object-fit:contain; border-radius:6px;
+}
+#gif-meta { line-height:1.5; }
+#gif-accept:disabled { opacity:.45; cursor:default; }
+
+/* Opt-in settings portability: never silently include local directory paths. */
+.profile-option { cursor:pointer; }
+.profile-option input[type=checkbox] {
+ width:18px; height:18px; flex:0 0 18px; accent-color:var(--accent);
+}
+.profile-option .note { flex:1; min-width:0; line-height:1.4; }
diff --git a/notro_app/picker/ui/app.js b/notro_app/picker/ui/app.js
index ed23c15..c58773d 100644
--- a/notro_app/picker/ui/app.js
+++ b/notro_app/picker/ui/app.js
@@ -73,7 +73,24 @@ function applyStrings() {
$("#st-folders-subtitle").textContent = str("picker_folders_subtitle");
$("#st-openlib").textContent = str("picker_open_library");
$("#st-addfolder").textContent = str("picker_add_folder");
+ $("#st-import-pack").textContent = str("picker_pack_import");
+ $("#st-export-pack").textContent = str("picker_pack_export");
+ $("#st-profile-title").textContent = str("picker_profile_title");
+ $("#st-profile-path-note").textContent = str("picker_profile_include_paths");
+ $("#st-profile-import").textContent = str("picker_profile_import");
+ $("#st-profile-export").textContent = str("picker_profile_export");
+ $("#profile-review-title").textContent = str("picker_profile_review_title");
+ $("#profile-review-note").textContent = str("picker_profile_review_note");
+ $("#profile-folders-label").textContent = str("picker_profile_folders_restore");
+ $("#profile-apply").textContent = str("picker_profile_confirm");
+ $("#profile-cancel").textContent = str("picker_profile_cancel");
$("#st-close").textContent = str("picker_cancel");
+ $("#gif-title").textContent = str("picker_gif_title");
+ $("#gif-note").textContent = str("picker_gif_note");
+ $("#gif-source-label").textContent = str("picker_gif_original");
+ $("#gif-result-label").textContent = str("picker_gif_result");
+ $("#gif-accept").textContent = str("picker_gif_accept");
+ $("#gif-cancel").textContent = str("picker_gif_cancel");
$("#dropzone").textContent = str("picker_drop_hint");
}
@@ -270,6 +287,8 @@ function showCtx(e, item) {
ctx.appendChild(b);
};
add(str("picker_ctx_file"), () => select(item, "file"));
+ if (item.animated)
+ add(str("picker_gif_optimize"), () => openGifPreview(item));
if (item.can_url) add(str("picker_ctx_url"), () => select(item, "url"));
if (!item.is_folder) {
add(item.favorite ? str("picker_ctx_unfavorite") : str("picker_ctx_favorite"),
@@ -299,10 +318,89 @@ function hideCtx() { $("#ctx").classList.add("hidden"); }
/* 열려 있는 창 하나 (없으면 undefined). 방향키·붙여넣기 처리는 창이 열려 있는
동안 그리드를 건드리면 안 된다. */
function openModal() {
- return [$("#modal-add"), $("#modal-edit"), $("#modal-settings")]
+ return [$("#modal-add"), $("#modal-edit"), $("#modal-settings"),
+ $("#modal-gif"), $("#modal-profile")]
.find((m) => !m.classList.contains("hidden"));
}
+/* ---------- GIF preview / explicit user approval ----------
+ This is an independent file-processing workflow. The optimization candidate
+ is a private temp file; no Discord client APIs are used. */
+let gifPreview = null;
+let gifPreviewTicket = 0;
+
+function gifMiB(bytes) { return (bytes / 1048576).toFixed(2) + " MB"; }
+function gifErrorKey(error) {
+ if (error === "already_fits") return "picker_gif_fits";
+ if (error === "too_complex") return "picker_gif_too_complex";
+ if (error === "cannot_fit") return "picker_gif_cannot_fit";
+ if (error === "outdated") return "picker_gif_outdated";
+ return "picker_gif_failed";
+}
+async function closeGifPreview() {
+ gifPreviewTicket++;
+ const candidate = gifPreview;
+ gifPreview = null;
+ $("#modal-gif").classList.add("hidden");
+ $("#gif-accept").disabled = true;
+ $("#gif-source-image").removeAttribute("src");
+ $("#gif-result-image").removeAttribute("src");
+ if (candidate && api()) await api().discard_gif_preview(candidate.token);
+}
+async function openGifPreview(item) {
+ if (!api()) return;
+ const ticket = ++gifPreviewTicket;
+ gifPreview = null;
+ $("#gif-compare").classList.add("hidden");
+ $("#gif-accept").disabled = true;
+ $("#gif-meta").textContent = "";
+ $("#gif-status").textContent = str("picker_gif_loading");
+ $("#modal-gif").classList.remove("hidden");
+
+ let res;
+ try { res = await api().preview_gif(item.id); }
+ catch (_) { res = {ok:false, error:"encode"}; }
+ if (ticket !== gifPreviewTicket) {
+ if (res && res.ok) await api().discard_gif_preview(res.token);
+ return;
+ }
+ if (!res || !res.ok) {
+ $("#gif-status").textContent = str(gifErrorKey(res && res.error));
+ return;
+ }
+ gifPreview = {itemId:item.id, token:res.token};
+ $("#gif-source-image").src = res.original_url;
+ $("#gif-result-image").src = res.optimized_url;
+ $("#gif-compare").classList.remove("hidden");
+ const oldSize = res.original_size.join("×");
+ const newSize = res.optimized_size.join("×");
+ const percent = Math.round(100 * (1 - res.optimized_bytes / res.original_bytes));
+ $("#gif-meta").textContent =
+ gifMiB(res.original_bytes) + " → " + gifMiB(res.optimized_bytes)
+ + " (−" + percent + "%), " + oldSize + " → " + newSize + " px"
+ + ", " + res.frames + " " + str("picker_gif_info");
+ $("#gif-status").textContent = str("picker_gif_result");
+ $("#gif-accept").disabled = false;
+}
+async function acceptGifPreview() {
+ if (!gifPreview || !api()) return;
+ const candidate = gifPreview;
+ $("#gif-accept").disabled = true;
+ let res;
+ try { res = await api().paste_gif_preview(candidate.itemId, candidate.token); }
+ catch (_) { res = {ok:false, error:"outdated"}; }
+ if (res && res.ok) {
+ gifPreview = null;
+ gifPreviewTicket++;
+ $("#modal-gif").classList.add("hidden");
+ } else {
+ $("#gif-status").textContent = str(gifErrorKey(res && res.error));
+ gifPreview = null;
+ }
+}
+$("#gif-cancel").addEventListener("click", closeGifPreview);
+$("#gif-accept").addEventListener("click", acceptGifPreview);
+
/* ---------- 등록 모달 ---------- */
/* 세로 바에서 실제 컬렉션을 보고 있으면 그곳을 기본 저장 위치로 쓴다.
전체(__all__)·즐겨찾기(__fav__)는 컬렉션이 아니므로 미분류. */
@@ -485,6 +583,79 @@ function renderFolders() {
}
}
+/* ---------- Safe, opt-in profile portability ----------
+ A native file dialog temporarily takes focus. Never hide the picker on
+ that expected blur. Restore only after a separate review/approval step.
+ Watched absolute paths stay unchecked by default for both directions.
+ The backend never re-enables auto-send or auto-capture from a backup. */
+let nativeDialogPending = false;
+let profileReview = null;
+async function withNativeDialog(task) {
+ if (nativeDialogPending) return {ok:false, error:"busy"};
+ nativeDialogPending = true;
+ try { return await task(); }
+ finally { nativeDialogPending = false; }
+}
+async function closeProfileReview() {
+ const review = profileReview;
+ profileReview = null;
+ $("#modal-profile").classList.add("hidden");
+ $("#profile-apply").disabled = false;
+ $("#profile-restore-folders").checked = false;
+ if (review && api()) await api().discard_preferences_preview(review.token);
+}
+async function inspectProfile() {
+ if (!api()) return;
+ const res = await withNativeDialog(() => api().preview_preferences());
+ if (!res || !res.ok) {
+ if (res && res.error !== "cancelled") flashHint("picker_profile_error");
+ return;
+ }
+ profileReview = {token:res.token};
+ $("#modal-settings").classList.add("hidden");
+ $("#profile-error").classList.add("hidden");
+ $("#profile-restore-folders").checked = false;
+ $("#profile-restore-folders").disabled = res.folders_count === 0;
+ const template = str("picker_profile_counts");
+ $("#profile-review-counts").textContent = template
+ .replace("{settings}", res.settings_count)
+ .replace("{folders}", res.folders_count);
+ $("#modal-profile").classList.remove("hidden");
+}
+async function applyProfile() {
+ if (!profileReview || !api()) return;
+ $("#profile-apply").disabled = true;
+ const token = profileReview.token;
+ const include = $("#profile-restore-folders").checked;
+ let res;
+ try { res = await api().apply_preferences(token, include); }
+ catch (_) { res = {ok:false}; }
+ if (res && res.ok) {
+ profileReview = null;
+ $("#modal-profile").classList.add("hidden");
+ await refresh();
+ $("#st-profile-include-folders").checked = false;
+ flashHint("picker_profile_restored", res);
+ } else {
+ $("#profile-error").textContent = str("picker_profile_error");
+ $("#profile-error").classList.remove("hidden");
+ profileReview = null;
+ }
+ $("#profile-apply").disabled = false;
+}
+$("#st-profile-export").addEventListener("click", async () => {
+ if (!api()) return;
+ const include = $("#st-profile-include-folders").checked;
+ const res = await withNativeDialog(
+ () => api().export_preferences(include));
+ if (res && res.ok) flashHint("picker_profile_exported", res);
+ else if (res && res.error !== "cancelled")
+ flashHint("picker_profile_error");
+});
+$("#st-profile-import").addEventListener("click", inspectProfile);
+$("#profile-apply").addEventListener("click", applyProfile);
+$("#profile-cancel").addEventListener("click", closeProfileReview);
+
/* ---------- 이벤트 배선 ---------- */
$("#search").addEventListener("input", (e) => { state.query = e.target.value; render(); });
document.querySelectorAll(".tab").forEach((b) =>
@@ -523,9 +694,21 @@ $("#st-auto-send").addEventListener("change", async (e) => {
e.target.checked = state.autoSend;
});
$("#st-addfolder").addEventListener("click", async () => {
- if (api()) { await api().add_folder(state.tab); refresh(); }
+ if (api()) { await withNativeDialog(() => api().add_folder(state.tab)); refresh(); }
});
$("#st-openlib").addEventListener("click", () => { if (api()) api().open_data_dir(); });
+$("#st-import-pack").addEventListener("click", async () => {
+ if (!api()) return;
+ const res = await withNativeDialog(() => api().import_pack());
+ if (res && res.ok) { await refresh(); flashHint("picker_pack_imported", res); }
+ else if (res && res.error !== "cancelled") flashHint("picker_pack_error");
+});
+$("#st-export-pack").addEventListener("click", async () => {
+ if (!api()) return;
+ const res = await withNativeDialog(() => api().export_pack());
+ if (res && res.ok) flashHint("picker_pack_done", res);
+ else if (res && res.error !== "cancelled") flashHint("picker_pack_error");
+});
document.addEventListener("click", (e) => { if (!$("#ctx").contains(e.target)) hideCtx(); });
/* 방향키/Enter는 검색창에 포커스가 있어도 그리드를 조작한다 (캐럿 이동보다
@@ -537,6 +720,8 @@ const CURSOR_MOVES = {
window.addEventListener("keydown", (e) => {
const modal = openModal();
if (e.key === "Escape") {
+ if (modal && modal.id === "modal-profile") { closeProfileReview(); return; }
+ if (modal && modal.id === "modal-gif") { closeGifPreview(); return; }
if (modal && modal.id === "modal-edit") editingId = "";
if (modal) { modal.classList.add("hidden"); return; }
if (!$("#ctx").classList.contains("hidden")) { hideCtx(); return; }
@@ -548,7 +733,9 @@ window.addEventListener("keydown", (e) => {
if (move) { e.preventDefault(); moveCursor(move[0], move[1]); return; }
if (e.key === "Enter") { e.preventDefault(); selectCursor(); }
});
-window.addEventListener("blur", () => { if (api()) api().hide(); });
+window.addEventListener("blur", () => {
+ if (api() && !nativeDialogPending && !profileReview) api().hide();
+});
/* ---------- 드래그앤드롭 (pywebviewFullPath) ---------- */
let dragDepth = 0;
@@ -561,6 +748,18 @@ window.addEventListener("drop", async (e) => {
$("#dropzone").classList.add("hidden");
if (!api()) return;
const paths = [...e.dataTransfer.files].map((f) => f.pywebviewFullPath).filter(Boolean);
+ if (paths.length === 1 && /\.zip$/i.test(paths[0])) {
+ // A pack drop is explicit opt-in, with exactly the same preflight as the
+ // Import Pack dialog. Other ZIPs are rejected; never extract arbitrary files.
+ const res = await api().import_pack_path(paths[0]);
+ if (res && res.ok) {
+ await refresh();
+ flashHint("picker_pack_imported", res);
+ } else {
+ flashHint("picker_pack_error");
+ }
+ return;
+ }
if (paths.length) {
const res = await api().register_files(paths, state.tab, currentCollection());
await refresh();
@@ -600,6 +799,21 @@ window.addEventListener("paste", async (e) => {
/* ---------- 표시 훅 (Python이 호출) ---------- */
window.__onShow = () => {
+ // The popup may have hidden or lost focus during a native file dialog.
+ profileReview = null;
+ nativeDialogPending = false;
+ $("#modal-profile").classList.add("hidden");
+ $("#profile-restore-folders").checked = false;
+ // A previous popup may have closed on lost focus while a preview/modal
+ // was pending. The Python controller has already discarded the preview.
+ // Reset the presentation before the new picker session begins.
+ gifPreviewTicket++;
+ gifPreview = null;
+ $("#modal-gif").classList.add("hidden");
+ $("#gif-accept").disabled = true;
+ $("#gif-compare").classList.add("hidden");
+ $("#gif-source-image").removeAttribute("src");
+ $("#gif-result-image").removeAttribute("src");
state.query = "";
$("#search").value = "";
hideCtx();
diff --git a/notro_app/picker/ui/index.html b/notro_app/picker/ui/index.html
index 6c0241c..04741f9 100644
--- a/notro_app/picker/ui/index.html
+++ b/notro_app/picker/ui/index.html
@@ -82,6 +82,19 @@
diff --git a/notro_app/picker/ui/tray_panel.css b/notro_app/picker/ui/tray_panel.css new file mode 100644 index 0000000..eb8bf1b --- /dev/null +++ b/notro_app/picker/ui/tray_panel.css @@ -0,0 +1,193 @@ +/* Notro independent Windows UI: Discord-inspired dark palette, not a + Discord client modification. No remote resources, fonts or trackers. */ +:root { + color-scheme: dark; + --bg: #17191f; + --surface: #23252e; + --surface-alt: #292c37; + --border: #393c47; + --ink: #f5f6f9; + --muted: #aeb3bf; + --accent: #5865f2; + --accent-light: #7984ff; + --positive: #3ba77a; + --warning: #f7c977; + --danger: #f57982; +} +* { box-sizing: border-box; } +html, body { + width: 100%; height: 100%; margin: 0; overflow: hidden; + background: var(--bg); color: var(--ink); + font: 13px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif; +} +body { user-select: none; -webkit-user-select: none; } +button, select { font: inherit; } +button:focus-visible, select:focus-visible { + outline: 2px solid var(--accent-light); outline-offset: -2px; +} +button { border: 0; background: transparent; color: inherit; cursor: pointer; } +button:hover { background: #383c4a; } +button:active { transform: translateY(1px); } +button[disabled] { opacity: .42; pointer-events: none; } +.hidden { display: none !important; } +#shell { + height: 100%; min-width: 310px; + border: 1px solid var(--border); border-radius: 12px; + background: linear-gradient(180deg, #20222c 0, var(--bg) 120px); + box-shadow: 0 16px 45px rgba(0, 0, 0, .45); + overflow: hidden; display: flex; flex-direction: column; +} +#brand-header { + flex: none; height: 69px; padding: 13px 17px 12px; + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, .08); +} +.brand-lockup { display: flex; align-items: center; gap: 10px; } +.brand-name { display: flex; flex-direction: column; gap: 0; } +.brand-name strong { font-size: 16px; letter-spacing: .15px; } +.brand-name span { font-size: 11px; } +.muted { color: var(--muted); } +.mark { + position: relative; width: 35px; height: 35px; border-radius: 10px; + background: linear-gradient(145deg, #7882ff 0%, #5260d8 100%); + box-shadow: 0 3px 15px rgba(88, 101, 242, .20); + flex: none; +} +.mark::before { + content: ""; position: absolute; width: 23px; height: 10px; + top: 13px; left: 6px; border-radius: 9px; background: #e9eaff; +} +.mark span { + position: absolute; left: 15px; top: 4px; height: 29px; width: 5px; + background: #26304c; transform: rotate(37deg); border-radius: 4px; +} +.mark.large { width: 60px; height: 60px; border-radius: 18px; margin: 0 auto 11px; } +.mark.large::before { width: 42px; height: 17px; top: 22px; left: 9px; } +.mark.large span { left: 27px; top: 9px; height: 47px; width: 8px; } +.status { + padding: 5px 9px; color: #bdeddc; background: #24463c; + border: 1px solid rgba(113, 197, 157, .17); + font-size: 11px; border-radius: 9px; font-weight: 700; +} +.status.paused { color: #ffdca0; background: #59432d; } +.page { + flex: 1; min-height: 0; padding: 12px 13px 14px; + overflow-y: auto; scrollbar-width: thin; scrollbar-color: #4c4e5f transparent; +} +.hero { + display: flex; gap: 11px; width: 100%; align-items: center; + text-align: left; padding: 15px 14px; min-height: 79px; + background: linear-gradient(130deg, #6571ef, #5362db); + border-radius: 10px; box-shadow: 0 5px 15px rgba(43, 50, 130, .18); +} +.hero:hover { background: linear-gradient(130deg, #7985f8, #626ff0); } +.hero:active { transform: scale(.995); } +.hero-icon { font-size: 23px; } +.hero-text { display: flex; flex: 1; flex-direction: column; gap: 4px; min-width: 0; } +.hero-text strong { font-size: 15px; letter-spacing: -.15px; } +.hero-text small { font-size: 11px; color: #eff0ff; opacity: .93; } +.arrow { font-size: 19px; } +.section-label { + margin: 15px 3px 8px; font-size: 10px; + font-weight: 800; color: #959aab; letter-spacing: .07em; + text-transform: uppercase; +} +.quick-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.quick { + background: var(--surface); + border: 1px solid rgba(255,255,255,.07); + border-radius: 9px; height: 67px; + display: flex; flex-direction: column; align-items: flex-start; + text-align: left; justify-content: center; padding: 9px 13px; + gap: 4px; font-weight: 600; +} +.quick:hover { border-color: #6974f0; } +.quick-icon { font-size: 18px; color: #a9b1ff; height: 19px; } +.mini-status { + display: flex; justify-content: space-between; align-items: center; + border-radius: 8px; margin-top: 9px; padding: 8px 12px; + color: var(--muted); background: #24262e; font-size: 12px; +} +.mini-status strong { color: #c4c9ff; font-size: 12px; } +.divider { border-top: 1px solid rgba(255,255,255,.095); margin: 13px 4px 8px; } +.menu-stack { display: flex; flex-direction: column; gap: 3px; } +.menu-item { + display: flex; align-items: center; justify-content: flex-start; + text-align: left; gap: 10px; padding: 10px 12px; + border-radius: 8px; width: 100%; min-height: 39px; + font-size: 13px; font-weight: 600; +} +.menu-item .chevron { font-size: 19px; color: #8991a7; margin-left: auto; } +.menu-symbol { font-size: 18px; width: 22px; text-align: center; color: #acb3c5; } +.menu-item.danger { color: var(--danger); } +.menu-item.danger:hover { background: #542b36; color: #ffe2e6; } +#home-footnote { + margin: 13px 4px 0; font-size: 10px; color: #8b90a0; + line-height: 1.5; border-top: 1px solid #343741; padding-top: 10px; +} +.back { + border-radius: 7px; min-height: 28px; padding: 4px 8px; + color: #c6c9ff; font-size: 12px; font-weight: 700; +} +.back span:first-child { font-size: 19px; vertical-align: -1px; } +h1 { font-size: 19px; font-weight: 750; margin: 10px 5px 18px; letter-spacing: -.35px; } +.settings-group { display: flex; flex-direction: column; gap: 6px; margin: 0 4px; } +.settings-group > label { color: #bdc2d0; font-size: 12px; margin-top: 7px; } +.settings-group > label:first-child { margin-top: 0; } +select { + color: var(--ink); background: var(--surface-alt); border: 1px solid #424656; + width: 100%; height: 36px; border-radius: 8px; padding: 5px 11px; +} +option { background: #272933; } +#limit-hint { display: block; margin: 0 2px 4px; font-size: 10px; line-height: 1.3; } +.check-row { min-height: 38px; } +.check-row > span:first-child { flex: 1; } +.toggle { + width: 30px; height: 17px; border-radius: 12px; + background: #656775; position: relative; flex: none; +} +.toggle::after { + content: ""; position: absolute; width: 13px; height: 13px; + border-radius: 50%; top: 2px; left: 2px; background: #f1f3f6; + transition: left .12s; +} +.toggle.enabled { background: #6673f3; } +.toggle.enabled::after { left: 15px; } +.about-hero { text-align: center; margin: 8px auto 0; } +.about-hero h1 { margin: 0 0 5px; font-size: 25px; } +.version-tag { + display: inline-block; border-radius: 13px; background: #343769; + color: #d1d6ff; font-size: 11px; font-weight: 700; padding: 3px 11px; +} +.about-hero p { margin: 10px 12px 15px; color: #c4c7d0; font-size: 11px; } +.info-lines { background: #22252f; border-radius: 9px; padding: 7px 12px; } +.info-lines div { display: flex; justify-content: space-between; gap: 10px; padding: 5px 1px; font-size: 11px; } +.info-lines div span { color: #a4abb9; } +.info-lines div strong { color: #e5e7ec; font-weight: 600; } +.disclosure { + margin: 11px 5px 8px; padding: 9px 11px; font-size: 10.5px; + line-height: 1.45; border: 1px solid #5c5138; + color: #d3c49b; background: #342e24; border-radius: 8px; +} +.link-group { margin-bottom: 6px; } +.link-group .menu-item { min-height: 34px; } +.toast { + position: absolute; bottom: 14px; right: 15px; left: 15px; + padding: 11px 13px; border-radius: 9px; + background: #414b6c; border: 1px solid #5968aa; + color: #fff; text-align: center; pointer-events: none; + font-size: 12px; box-shadow: 0 12px 20px rgba(0,0,0,.3); +} +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} + +/* Repository stays visible above the fold even at 150-200% display DPI. */ +.about-source-link { + display: inline-flex; align-items: center; justify-content: center; + gap: 7px; margin: 0 auto 10px; padding: 6px 10px; + max-width: 100%; overflow-wrap: anywhere; border: 1px solid #4d5591; + background: #262b49; border-radius: 8px; color: #d8dcff; + font-size: 10px; line-height: 1.3; +} +.about-source-link:hover { background: #353c71; color: #fff; } diff --git a/notro_app/picker/ui/tray_panel.html b/notro_app/picker/ui/tray_panel.html new file mode 100644 index 0000000..93a378a --- /dev/null +++ b/notro_app/picker/ui/tray_panel.html @@ -0,0 +1,129 @@ + + +
+ + +
+ + +
+