From cc9b48b64b03d9bf1bbab2ab7a3cf99c8d98ee22 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 16:43:00 -0700 Subject: [PATCH 01/33] feat(notro): refresh Discord limits, safe media packs, QA and Plane workflow --- .github/workflows/ci.yml | 2 + README.es.md | 26 ++- README.ja.md | 26 ++- README.ko.md | 29 ++- README.md | 29 ++- README.zh.md | 24 +- docs/marketing/landing-copy-en-ko.md | 26 +++ docs/marketing/launch-kit-2026-10.md | 42 ++++ docs/qa/2026-10-13-notro-readiness.md | 38 ++++ docs/research/2026-10-13-rea-discord-audit.md | 27 +++ docs/show-hn-draft.md | 11 +- notro_app/app.py | 5 +- notro_app/config.py | 8 +- notro_app/i18n.py | 35 +++ notro_app/library.py | 12 +- notro_app/monitor.py | 6 +- notro_app/packs.py | 214 ++++++++++++++++++ notro_app/picker/assets_server.py | 32 ++- notro_app/picker/ui/app.js | 14 ++ notro_app/picker/ui/index.html | 4 + notro_app/picker/window.py | 48 +++- notro_app/tray.py | 4 +- notro_app/welcome.py | 19 +- requirements-dev.txt | 1 + scripts/benchmark_library.py | 52 +++++ scripts/plane_sync.py | 168 ++++++++++++++ tests/test_assets_server.py | 24 ++ tests/test_config.py | 17 ++ tests/test_ffmpeg_setup.py | 1 - tests/test_library.py | 27 ++- tests/test_monitor_capture.py | 1 + tests/test_monitor_races.py | 12 + tests/test_monitor_video.py | 4 +- tests/test_packs.py | 123 ++++++++++ tests/test_picker_ui_contract.py | 7 + tests/test_welcome.py | 16 ++ 36 files changed, 1054 insertions(+), 80 deletions(-) create mode 100644 docs/marketing/landing-copy-en-ko.md create mode 100644 docs/marketing/launch-kit-2026-10.md create mode 100644 docs/qa/2026-10-13-notro-readiness.md create mode 100644 docs/research/2026-10-13-rea-discord-audit.md create mode 100644 notro_app/packs.py create mode 100644 scripts/benchmark_library.py create mode 100644 scripts/plane_sync.py create mode 100644 tests/test_assets_server.py create mode 100644 tests/test_packs.py create mode 100644 tests/test_welcome.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e02206..9f20398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,5 +16,7 @@ jobs: cache: pip - name: Install dependencies run: pip install -r requirements-dev.txt + - name: Lint Python + run: ruff check notro_app tests - name: Run tests run: python -m pytest -q diff --git a/README.es.md b/README.es.md index 5a84e80..d1bbe42 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. @@ -45,6 +45,8 @@ quien lo recibe ve tus emojis como imágenes adjuntas, no como emojis integrados --- +> Según la [FAQ oficial de Discord](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ), actualizada el 13 de agosto de 2026, los límites son 20 MB (gratis), 50 MB (Nitro Basic) y 500 MB (Nitro). Se conserva la opción de 10 MB. Discord puede probar otros límites con algunos usuarios. + ## El selector Ctrl+Shift+E en cualquier sitio abre una ventana oscura @@ -90,17 +92,27 @@ cierra. Después del atajo no hace falta soltar el teclado. +## 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. + ## 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 +120,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 +203,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..a834bb4 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 を 押して選べば、入力欄に入ります。 @@ -45,6 +45,8 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か --- +> 2026年8月13日更新の[Discord公式FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ)では、無料20MB・Nitro Basic 50MB・Nitro 500MBです。10MBは任意の低い上限として残しています。Discordの試験運用で上限が異なる場合があります。 + ## ピッカー どこでも Ctrl+Shift+E を押すと、カーソルの近くに暗い @@ -84,28 +86,36 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か +## メディアパックのインポート・エクスポート + +ピッカーの設定から、登録した絵文字・スタンプ・GIF と検索用の名前・キーワードを ZIP +形式で書き出し、別の端末に読み込めます。読み込み前にファイル形式・ハッシュ・パスを検証し、重複を除外します。 + +個人用の Captures、Discord の元 URL、クリップボード履歴、監視フォルダーのパス、Windows の設定は +含まれません。設定全体のバックアップではありません。共有権限があるメディアのみ配布してください。 + ## 自動圧縮 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 +189,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..308e9a2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -19,9 +19,9 @@ --- -디스코드가 돈을 받는 두 가지를, 앱 바깥에서 해결합니다. +디스코드의 자주 겪는 불편 두 가지를, 클라이언트 수정 없이 해결합니다. -- **이미지가 14MB인데 무료 한도는 10MB입니다.** 복사하는 순간 Notro가 알아서 줄여 +- **이미지가 24MB인데 무료 한도는 20MB입니다.** 복사하는 순간 Notro가 알아서 줄여 클립보드에 되돌려 놓습니다. Ctrl+V만 누르면 됩니다. - **커스텀 이모지를 어디서든 쓰고 싶습니다.** Ctrl+Shift+E를 누르고 고르면 입력창에 들어갑니다. @@ -45,6 +45,8 @@ --- +> 2026년 8월 13일 갱신된 [Discord 공식 첨부파일 FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ)를 기준으로 무료 20MB, Nitro Basic 50MB, Nitro 500MB이며, 10MB는 선택 가능한 낮은 한도로 유지합니다. Discord의 사용자별 실험으로 한도가 다를 수 있습니다. + ## 피커 어디서든 Ctrl+Shift+E를 누르면 커서 옆에 어두운 팝업이 @@ -82,28 +84,37 @@ +## 미디어 팩 가져오기·내보내기 + +피커 설정에서 **팩 내보내기** 또는 **팩 가져오기**를 선택하면 이모지·스티커·GIF +항목과 검색용 이름·키워드를 ZIP으로 옮길 수 있습니다. 가져오기 전에 파일 형식·해시·경로를 +검증하고 중복 항목은 건너뜁니다. + +개인 **캡처** 컬렉션, Discord 원본 URL, 클립보드 기록, 감시 폴더 경로와 Windows 설정은 +내보내지 않습니다. 전체 설정 백업이 아니며, 공유할 권리가 있는 미디어만 전달하세요. + ## 자동 압축 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 +186,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..e62d043 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. @@ -45,6 +45,8 @@ your emoji as image attachments, not as native inline emoji. --- +> Upload limits updated August 13, 2026: [Discord File Attachments FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ). Free 20 MB / Nitro Basic 50 MB / Nitro 500 MB; 10 MB remains as an optional lower limit. Discord may experiment with different limits. + ## The picker Ctrl+Shift+E anywhere opens a dark popup at your cursor @@ -86,29 +88,40 @@ collection with no registration step at all. +## 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. + ## 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 @@ -183,8 +196,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 | diff --git a/README.zh.md b/README.zh.md index ab6c5f8..73de12c 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 挑一个,它就进到输入框里。 @@ -44,6 +44,8 @@ Discord 要收费的两件事,在应用之外解决: --- +> 根据 2026 年 8 月 13 日更新的 [Discord 官方附件 FAQ](https://support.discord.com/hc/en-us/articles/25444343291031-File-Attachments-FAQ),免费为 20MB、Nitro Basic 为 50MB、Nitro 为 500MB;10MB 保留为较低限额的自选项。部分用户可能处于不同限额的实验中。 + ## 选择器 在任何地方按 Ctrl+Shift+E,光标旁会弹出一个深色窗口, @@ -78,26 +80,34 @@ Discord 要收费的两件事,在应用之外解决: +## 导入与导出媒体资源包 + +在选择器的设置中可将已注册的表情、贴纸、GIF 及搜索名称和关键词导出为 ZIP,再导入另一台设备。 +导入前会检查文件格式、哈希和路径,并跳过重复项。 + +不会导出私人 Captures、Discord 原始链接、剪贴板历史、监视文件夹路径或 Windows 设置。 +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 +174,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/docs/marketing/landing-copy-en-ko.md b/docs/marketing/landing-copy-en-ko.md new file mode 100644 index 0000000..7884e66 --- /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-08-13: free 20MB, Nitro Basic 50MB, Nitro 500MB). 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..600256b --- /dev/null +++ b/docs/marketing/launch-kit-2026-10.md @@ -0,0 +1,42 @@ +# 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. Secure installer distribution trust (signing path or clear unsigned disclosure) and real E2E QA. +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. 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..8af3a5a --- /dev/null +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -0,0 +1,38 @@ +# 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 test suite and ruff lint run as part of the release candidate checks. +- 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. 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..ae76498 100644 --- a/docs/show-hn-draft.md +++ b/docs/show-hn-draft.md @@ -21,7 +21,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 +30,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. @@ -42,7 +42,7 @@ 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 + 52MB · 1:12 · 1080p60 → about 19MB · 720p60 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 @@ -54,8 +54,7 @@ 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 +75,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" diff --git a/notro_app/app.py b/notro_app/app.py index bbc8f1c..01c755e 100644 --- a/notro_app/app.py +++ b/notro_app/app.py @@ -389,7 +389,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/config.py b/notro_app/config.py index a94629b..51ca9b0 100644 --- a/notro_app/config.py +++ b/notro_app/config.py @@ -13,8 +13,8 @@ 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 # 클립보드 확인 주기 (초) # ================================================ @@ -24,13 +24,15 @@ def compute_limit_bytes(mb: int) -> int: return int(mb * 1024 * 1024 * SAFETY) +UPLOAD_CEILING_BYTES = LIMIT_MB * 1024 * 1024 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 = mb * 1024 * 1024 LIMIT_BYTES = compute_limit_bytes(mb) diff --git a/notro_app/i18n.py b/notro_app/i18n.py index a45b072..6e5b243 100644 --- a/notro_app/i18n.py +++ b/notro_app/i18n.py @@ -764,3 +764,38 @@ 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 diff --git a/notro_app/library.py b/notro_app/library.py index 311fcb9..fd3fc77 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: 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..cafd700 --- /dev/null +++ b/notro_app/packs.py @@ -0,0 +1,214 @@ +# -*- 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 +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 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: + """Validate entire ZIP before mutating the library; merge duplicates safely.""" + path = Path(archive) + if path.suffix.lower() != ".zip" or not path.is_file(): + raise PackError("not a ZIP media pack") + 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") + manifest_bytes = z.read("manifest.json") + 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..c2fd161 100644 --- a/notro_app/picker/assets_server.py +++ b/notro_app/picker/assets_server.py @@ -42,21 +42,29 @@ def do_GET(self): # noqa: N802 (http.server 규약) return ctype = mimetypes.guess_type(path)[0] or "application/octet-stream" try: + # Do not read a potentially large user GIF wholly into RAM. + # One request uses a bounded 64 KiB transfer buffer. with open(path, "rb") as f: - data = f.read() - 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) + size = os.fstat(f.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() + while True: + chunk = f.read(65536) + if not chunk: + break + self.wfile.write(chunk) except (ConnectionAbortedError, BrokenPipeError): pass + except OSError: + if not self.wfile.closed: + try: + self.send_error(404) + except OSError: + pass def log_message(self, *args): # 콘솔 소음 제거 pass diff --git a/notro_app/picker/ui/app.js b/notro_app/picker/ui/app.js index ed23c15..f15b671 100644 --- a/notro_app/picker/ui/app.js +++ b/notro_app/picker/ui/app.js @@ -73,6 +73,8 @@ 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-close").textContent = str("picker_cancel"); $("#dropzone").textContent = str("picker_drop_hint"); } @@ -526,6 +528,18 @@ $("#st-addfolder").addEventListener("click", async () => { if (api()) { await 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 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 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는 검색창에 포커스가 있어도 그리드를 조작한다 (캐럿 이동보다 diff --git a/notro_app/picker/ui/index.html b/notro_app/picker/ui/index.html index 6c0241c..c13e897 100644 --- a/notro_app/picker/ui/index.html +++ b/notro_app/picker/ui/index.html @@ -82,6 +82,10 @@

+
+ + +
diff --git a/notro_app/picker/window.py b/notro_app/picker/window.py index ec0c0c6..960ba08 100644 --- a/notro_app/picker/window.py +++ b/notro_app/picker/window.py @@ -140,7 +140,7 @@ def popup_geometry() -> tuple[int, int, int, int]: def prepare_for_paste(path: str, limit_bytes: int, temp_dir: str, - target_px: int = 0) -> tuple[str, bool]: + target_px: int = 0, upload_ceiling_bytes: int | None = None) -> tuple[str, bool]: """붙여넣기 전처리 (스펙 §6.7/§6.8/§7). 1) 표시 크기 정규화: target_px > 0이면 긴 변을 그 크기로 맞춘다 (§6.8). @@ -157,7 +157,9 @@ def prepare_for_paste(path: str, limit_bytes: int, temp_dir: str, size = os.path.getsize(path) except OSError: return path, False - if size <= limit_bytes: + # Only compress files that exceed the actual upload ceiling. The safety + # margin belongs to the encoded output target, not the detection threshold. + if size <= (upload_ceiling_bytes if upload_ceiling_bytes is not None else limit_bytes): return path, False if path.lower().endswith(".gif"): return path, True @@ -207,6 +209,8 @@ def prepare_for_paste(path: str, limit_bytes: int, temp_dir: str, "picker_auto_send", "picker_auto_send_note", "picker_edit_title", "picker_edit_note", "picker_edit_save", "picker_ctx_edit", "picker_err_duplicate", "picker_drop_duplicate", + "picker_pack_import", "picker_pack_export", "picker_pack_done", + "picker_pack_imported", "picker_pack_error", ] @@ -352,6 +356,43 @@ def set_paste_size(self, type_: str, px) -> dict: return {"ok": True, "type": type_, "px": resize.set_target_px(type_, px)} + def export_pack(self) -> dict: + """Explicit dialog; export only registered non-Captures images.""" + import webview + from .. import packs + + dialog = getattr(getattr(webview, "FileDialog", None), "SAVE", + webview.SAVE_DIALOG) + chosen = self._ctrl.window.create_file_dialog( + dialog, save_filename="notro-pack.zip", + file_types=("ZIP archives (*.zip)",)) + if not chosen: + return {"ok": False, "error": "cancelled"} + path = chosen[0] if isinstance(chosen, (list, tuple)) else chosen + try: + result = packs.export_pack(self._library, path) + except Exception: + return {"ok": False, "error": "pack"} + return {"ok": True, "count": result["count"]} + + def import_pack(self) -> dict: + """Explicit dialog; preflight ZIP before library mutations.""" + import webview + from .. import packs + + dialog = getattr(getattr(webview, "FileDialog", None), "OPEN", + webview.OPEN_DIALOG) + chosen = self._ctrl.window.create_file_dialog( + dialog, file_types=("ZIP archives (*.zip)",)) + if not chosen: + return {"ok": False, "error": "cancelled"} + path = chosen[0] if isinstance(chosen, (list, tuple)) else chosen + try: + result = packs.import_pack(self._library, path) + except Exception: + return {"ok": False, "error": "pack"} + return {"ok": True, **result} + def register_clipboard(self, type_: str, collection: str = "") -> dict: """클립보드의 PNG 이미지를 라이브러리에 등록 (스펙 §5 — 디스코드/브라우저 에서 이미지를 복사한 뒤 붙여넣는 주 경로). 재인코딩 추정이 아닌 클립보드 @@ -446,7 +487,8 @@ def select(self, item_id: str, mode: str = "file") -> None: else: path, warn = prepare_for_paste( self.library.asset_path(item), config.LIMIT_BYTES, - config.TEMP_DIR, target_px=resize.target_px_for(item["type"])) + config.TEMP_DIR, target_px=resize.target_px_for(item["type"]), + upload_ceiling_bytes=config.UPLOAD_CEILING_BYTES) ok = cb.set_clipboard_file(path) focused = cb.focus_window(self.prev_hwnd) if ok and focused: diff --git a/notro_app/tray.py b/notro_app/tray.py index 26bff91..46159be 100644 --- a/notro_app/tray.py +++ b/notro_app/tray.py @@ -120,9 +120,11 @@ def on_select(icon, item): radio=True, ) - # 디스코드 한도: 무료 10 / 니트로 베이직 50 / 니트로 500 MB + # Discord File Attachments FAQ (2026-08-13): free 20, Basic 50, Nitro 500MB. + # Preserve 10MB as a lower custom option for existing users. limit_menu = pystray.Menu( make_limit_item(10), + make_limit_item(20), make_limit_item(50), make_limit_item(500), ) diff --git a/notro_app/welcome.py b/notro_app/welcome.py index c1e5533..e486d56 100644 --- a/notro_app/welcome.py +++ b/notro_app/welcome.py @@ -61,6 +61,7 @@ font-size: 14px; font-weight: 600; cursor: pointer; } button:hover { background: #4752c4; } + button.secondary { margin-top: 8px; background: #45474e; } """ @@ -91,8 +92,12 @@ def build_html(combo_label: str | None = None) -> str: + "
  • " + e(tr("welcome_compress")) + "
  • " "
  • " + e(tr("welcome_menu")) + "
  • " "" - '" + '" + '" " diff --git a/notro_app/picker/window.py b/notro_app/picker/window.py index 7c27926..01729b2 100644 --- a/notro_app/picker/window.py +++ b/notro_app/picker/window.py @@ -12,6 +12,7 @@ from .. import config from ..capture_store import (CAPTURE_COLLECTION_ID, CaptureStore, read_clipboard_png) +from .gif_preview import GifPreviewMixin user32 = ctypes.windll.user32 @@ -211,6 +212,11 @@ def prepare_for_paste(path: str, limit_bytes: int, temp_dir: str, "picker_ctx_edit", "picker_err_duplicate", "picker_drop_duplicate", "picker_pack_import", "picker_pack_export", "picker_pack_done", "picker_pack_imported", "picker_pack_error", + "picker_gif_optimize", "picker_gif_title", "picker_gif_loading", + "picker_gif_original", "picker_gif_result", "picker_gif_accept", + "picker_gif_cancel", "picker_gif_note", "picker_gif_fits", + "picker_gif_cannot_fit", "picker_gif_too_complex", "picker_gif_failed", + "picker_gif_outdated", "picker_gif_info", ] @@ -221,7 +227,7 @@ def _collection(name) -> str: return "" if name in ("__all__", "__fav__") else name -class PickerApi: +class PickerApi(GifPreviewMixin): """JS 브리지. 각 메서드는 pywebview API 스레드에서 호출된다. 주의: pywebview는 js_api 객체의 공개 속성을 재귀 탐색해 JS에 노출한다. @@ -235,6 +241,7 @@ def __init__(self, library=None, asset_server=None, capture_store=None): self._asset_server = asset_server self._capture_store = capture_store or ( CaptureStore(library) if library is not None else None) + self._init_gif_preview() def _display(self, item: dict) -> dict: from ..i18n import tr @@ -477,8 +484,9 @@ def _notify(self, msg: str) -> None: except Exception: pass - def select(self, item_id: str, mode: str = "file") -> None: - """피커 선택 → 숨김 → 클립보드 → 포커스 복귀 → Ctrl+V. 전송은 사용자.""" + def select(self, item_id: str, mode: str = "file", + override_path: str | None = None) -> bool: + """Picker selection -> OS clipboard -> original focused window.""" import time as _t from .. import config, resize @@ -486,10 +494,12 @@ def select(self, item_id: str, mode: str = "file") -> None: item = self._resolve(item_id) if not item: - return + return False self.hide() warn = False - if mode == "url" and item.get("source_url"): + if override_path is not None: + ok = cb.set_clipboard_file(override_path) + elif mode == "url" and item.get("source_url"): ok = cb.set_clipboard_text(item["source_url"]) else: path, warn = prepare_for_paste( @@ -499,19 +509,19 @@ def select(self, item_id: str, mode: str = "file") -> None: ok = cb.set_clipboard_file(path) if not ok: self._notify(tr("notify_clipboard_fail")) - return + return False target_hwnd = self.prev_hwnd if not cb.focus_window(target_hwnd): self._notify(tr("notify_paste_manual")) - return + return False # Focus may change while the picker closes or an attachment is prepared. # Never press keys into whatever window the user switched to. _t.sleep(0.12) if not cb.is_window_focused(target_hwnd): self._notify(tr("notify_paste_manual")) - return + return False cb.send_ctrl_v() self.library.touch(item["id"]) @@ -522,7 +532,7 @@ def select(self, item_id: str, mode: str = "file") -> None: # automatic Enter is restricted to an unmodified Discord desktop # process, not Discord Web or other chat applications. Check again # immediately before sending: upload/draft focus can change mid-delay. - if mode != "url" and config.get_setting_flag("auto_send"): + if mode == "file" and override_path is None and config.get_setting_flag("auto_send"): if not cb.is_discord_desktop_window(target_hwnd): self._notify(tr("notify_auto_send_discord_only")) else: @@ -532,6 +542,7 @@ def select(self, item_id: str, mode: str = "file") -> None: cb.send_enter() else: self._notify(tr("notify_auto_send_focus_changed")) + return True def create_window(self): import webview @@ -630,6 +641,13 @@ def show_at_cursor(self): pass def hide(self): + # Closing the picker without accepting a GIF must not retain its + # temporary preview or expose its URL until the next app restart. + if self._api: + try: + self._api.discard_gif_preview() + except Exception: + pass try: self._remember_size() except Exception: diff --git a/notro_app/resize.py b/notro_app/resize.py index 4df780b..5019eda 100644 --- a/notro_app/resize.py +++ b/notro_app/resize.py @@ -96,21 +96,24 @@ def scaled_size(w: int, h: int, target_px: int) -> tuple[int, int]: # ---------- GIF 저장 ---------- -def save_gif(frames, durations, dest: str) -> None: - """RGBA 프레임 목록을 애니메이션 GIF로 저장한다. +def save_gif(frames, durations, dest: str, *, colors: int = 255, + loop: int = 0, optimize: bool = False) -> None: + """RGBA frames -> GIF. Optional palette/loop settings for opt-in preview. - GIF 투명도는 1비트라 알파<128은 완전 투명으로 처리하고, 팔레트 255번을 - 투명색으로 예약한다. + GIF alpha is one bit: alpha < 128 becomes transparent at palette index 255. + Existing callers use unchanged defaults; the optimizer can reduce colors + and preserve the source animation's loop setting. """ out = [] for f in frames: alpha = f.getchannel("A") - p = f.convert("RGB").convert("P", palette=Image.ADAPTIVE, colors=255) + p = f.convert("RGB").convert("P", palette=Image.ADAPTIVE, colors=colors) mask = alpha.point(lambda a: 255 if a < 128 else 0) p.paste(255, mask) out.append(p) out[0].save(dest, format="GIF", save_all=True, append_images=out[1:], - duration=durations, loop=0, disposal=2, transparency=255) + duration=durations, loop=loop, disposal=2, + transparency=255, optimize=optimize) # ---------- 캐시 ---------- diff --git a/scripts/plane_sync.py b/scripts/plane_sync.py index 3aeb5f3..8599bf5 100644 --- a/scripts/plane_sync.py +++ b/scripts/plane_sync.py @@ -42,8 +42,8 @@ "First-run Try Picker action implemented and localized. Confirm 3-minute human first-paste usability on Windows WebView2 before Done."), ("Shareable safe emoji pack import/export and library backup", "QA", "high", "Portable ZIP media packs, hashed assets, import/export UI and single-ZIP drag-and-drop with private Captures exclusion are implemented and tested (native fixture ZIP-drop PASS). WebView2 dialogs require QA. Full app settings backup is separate."), - ("Oversized GIF optimization with transparent size/quality preview", "To Do", "high", - "Optimize oversized GIFs only with explicit consent; prefer loss-minimized frame and scale strategies, preserve original file, surface result quality and failure."), + ("Oversized GIF optimization with transparent size/quality preview", "QA", "high", + "Implemented a bounded palette-first GIF candidate engine, animated before/after UI, actual upload-ceiling trigger, original file/timing preservation, explicit approval and manual-send-only clipboard path. Native WebView2 fixture validates both animated thumbnails, cancel cleanup and mock accepted paste. Unit/regression tests pass; actual Discord private-draft delivery, large real-world GIF visual-quality acceptance and installer packaging remain to verify."), ("Picker 100/1k/5k assets benchmark and asset server hardening", "QA", "high", "Synthetic 100/1k/5k scan/search benchmarks and bounded 64KiB local media streaming with unit tests implemented. Real WebView2 DOM/RAM and GIF scrolling still require QA."), ("English-first landing SEO update for writingdeveloper.blog/projects/notro", "QA", "high", diff --git a/scripts/qa_native_webview.py b/scripts/qa_native_webview.py index 9b25f25..123c5b5 100644 --- a/scripts/qa_native_webview.py +++ b/scripts/qa_native_webview.py @@ -8,16 +8,20 @@ from __future__ import annotations import json +import hashlib +import os +import random import tempfile import sys import time +from types import SimpleNamespace from pathlib import Path from PIL import Image sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from notro_app import fetch, packs +from notro_app import config, fetch, packs, resize from notro_app.library import Library from notro_app.i18n import set_language, tr from notro_app.picker.assets_server import AssetServer @@ -39,6 +43,28 @@ def main() -> int: fetch.register_from_file(lib, str(src), "emoji") pack_path = root / "test-pack.zip" packs.export_pack(lib, str(pack_path)) + + # A synthetic animated GIF is kept separate from the ZIP fixture. + # No live library, clipboard contents or Discord chats are accessed. + rng = random.Random(13) + gif_frames = [ + Image.frombytes("RGB", (100, 90), rng.randbytes(100 * 90 * 3)) + for _ in range(14) + ] + gif_path = root / "synthetic-animation.gif" + gif_frames[0].save( + gif_path, format="GIF", save_all=True, + append_images=gif_frames[1:], duration=80, loop=3, + ) + gif_item = fetch.register_from_file( + lib, str(gif_path), "gif", name="synthetic-animation") + lib_gif_path = lib.asset_path(gif_item) + gif_digest = hashlib.sha256(Path(lib_gif_path).read_bytes()).hexdigest() + # Runtime-only per-process values, no registry writes. + config.TEMP_DIR = str(root / "temporary") + config.LIMIT_BYTES = int(os.path.getsize(lib_gif_path) * .65) + config.UPLOAD_CEILING_BYTES = int(os.path.getsize(lib_gif_path) * .80) + resize.target_px_for = lambda _type: 0 server = AssetServer( lambda item_id: lib.asset_path(item) if (item := lib.resolve(item_id)) else None ) @@ -52,7 +78,9 @@ def main() -> int: result = {"native_webview2": False, "items": 0, "tabs": 0, "settings": False, "pack_buttons": False, "search": False, "asset_images_loaded": False, "locale_labels": [], - "zip_drop": False} + "zip_drop": False, "gif_preview": False, + "gif_source_preserved": False, "gif_cancelled": False, + "gif_mock_accept": False} errors = [] def examine(): @@ -67,12 +95,12 @@ def examine(): result["tabs"] = int(win.evaluate_js( "document.querySelectorAll('.tab').length" )) - if result["native_webview2"] and result["items"] == 2: + if result["native_webview2"] and result["items"] == 3: break except Exception: pass time.sleep(0.3) - if result["native_webview2"] and result["items"] == 2: + if result["native_webview2"] and result["items"] == 3: win.evaluate_js("document.getElementById('btn-settings').click()") result["settings"] = bool(win.evaluate_js( "!document.getElementById('modal-settings').classList.contains('hidden')" @@ -131,6 +159,69 @@ def examine(): result["zip_drop"] = True break time.sleep(0.1) + + # Click real GIF tab, request a candidate through pywebview + # JS->Python bridge, inspect both animated thumbnails, then + # cancel it. Never call paste_gif_preview during smoke QA. + win.evaluate_js( + "state.tab='gif';state.query='';render();" + "void openGifPreview(state.items.find(" + "i => i.name === 'synthetic-animation'))" + ) + for _ in range(60): + ready = bool(win.evaluate_js( + "!document.getElementById('gif-accept').disabled " + "&& !document.getElementById('gif-compare').classList" + ".contains('hidden')" + )) + if ready: + result["gif_preview"] = bool(win.evaluate_js( + "['gif-source-image','gif-result-image'].every(" + "id => {const i=document.getElementById(id);" + "return i.complete && i.naturalWidth > 0})" + )) + if result["gif_preview"]: + break + time.sleep(0.25) + win.evaluate_js("document.getElementById('gif-cancel').click()") + for _ in range(25): + if api._gif_preview is None: + result["gif_cancelled"] = True + break + time.sleep(0.1) + result["gif_source_preserved"] = ( + hashlib.sha256(Path(lib_gif_path).read_bytes()).hexdigest() + == gif_digest + ) + # Explicit accept path: substitute an inert controller, + # never call global clipboard or send key events. + # The actual JS->Python bridge must accept a generated + # candidate and exit the dialog without touching Discord. + calls = [] + api._ctrl = SimpleNamespace( + select=lambda id_, mode="file", override_path=None: + calls.append((id_, mode, override_path)) or True + ) + win.evaluate_js( + "void openGifPreview(state.items.find(" + "i => i.name === 'synthetic-animation'))" + ) + for _ in range(60): + if bool(win.evaluate_js( + "!document.getElementById('gif-accept').disabled" + )): + break + time.sleep(0.25) + win.evaluate_js("document.getElementById('gif-accept').click()") + for _ in range(25): + if calls: + break + time.sleep(0.1) + result["gif_mock_accept"] = bool( + len(calls) == 1 and calls[0][0] == gif_item["id"] + and calls[0][1] == "optimized" + and calls[0][2] and Path(calls[0][2]).is_file() + ) except Exception as e: errors.append(type(e).__name__ + ": " + str(e)[:300]) finally: @@ -146,11 +237,13 @@ def examine(): server.stop() result["errors"] = errors result["passed"] = bool( - result["native_webview2"] and result["items"] == 2 + result["native_webview2"] and result["items"] == 3 and result["tabs"] == 3 and result["settings"] and result["pack_buttons"] and result["search"] and result["asset_images_loaded"] and len(result["locale_labels"]) == 5 and result["zip_drop"] + and result["gif_preview"] and result["gif_cancelled"] + and result["gif_source_preserved"] and result["gif_mock_accept"] and not errors ) print("NOTRO_NATIVE_QA=" + json.dumps(result, ensure_ascii=True), flush=True) diff --git a/tests/test_gif_optimizer.py b/tests/test_gif_optimizer.py new file mode 100644 index 0000000..ea01c86 --- /dev/null +++ b/tests/test_gif_optimizer.py @@ -0,0 +1,107 @@ +"""Opt-in GIF compression: animation, timing, no originals mutated and bounds.""" +from __future__ import annotations + +import hashlib +import os +import random +from pathlib import Path + +import pytest +from PIL import Image, ImageSequence + +from notro_app.gif_optimizer import ( + GIFOptimizationError, MAX_FRAMES, MAX_SOURCE_BYTES, optimize_gif, +) + + +def make_gif(tmp_path, frames=14, size=(100, 90)): + rnd = random.Random(7) + images = [ + Image.frombytes("RGB", size, rnd.randbytes(size[0] * size[1] * 3)) + for _ in range(frames) + ] + path = tmp_path / "original.gif" + images[0].save( + path, format="GIF", save_all=True, append_images=images[1:], + duration=70, loop=2, + ) + return path + + +def checksum(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def test_optimized_gif_is_new_file_preserving_timing_and_loop(tmp_path): + source = make_gif(tmp_path) + before = checksum(source) + candidate = optimize_gif(str(source), str(tmp_path / "temporary"), + int(source.stat().st_size * .65)) + assert os.path.exists(candidate.path) and candidate.path != str(source) + assert candidate.optimized_bytes < candidate.original_bytes * .65 + assert candidate.colors <= 128 + assert checksum(source) == before + + with Image.open(source) as old, Image.open(candidate.path) as optimized: + assert optimized.format == "GIF" and optimized.n_frames == old.n_frames + assert optimized.info["loop"] == old.info["loop"] == 2 + assert optimized.size == candidate.optimized_size + duration_old = sum(frame.info.get("duration", 0) + for frame in ImageSequence.Iterator(old)) + duration_new = sum(frame.info.get("duration", 0) + for frame in ImageSequence.Iterator(optimized)) + assert duration_old == duration_new == 980 + + +def test_optimized_gif_may_downscale_only_after_palette_trials(tmp_path): + source = make_gif(tmp_path) + candidate = optimize_gif(str(source), str(tmp_path / "candidate"), + int(source.stat().st_size * .4)) + assert candidate.scale < 1.0 + assert max(candidate.optimized_size) < max(candidate.original_size) + assert candidate.optimized_bytes < candidate.original_bytes * .4 + + +def test_no_encode_for_already_fitting_source(tmp_path): + source = make_gif(tmp_path) + with pytest.raises(GIFOptimizationError) as exc: + optimize_gif(str(source), str(tmp_path / "out"), + source.stat().st_size * 2) + assert exc.value.code == "already_fits" + assert not (tmp_path / "out").exists() + + +def test_gif_frame_count_limit_without_output(tmp_path): + source = make_gif(tmp_path, frames=MAX_FRAMES + 1, size=(28, 28)) + with pytest.raises(GIFOptimizationError) as exc: + optimize_gif(str(source), str(tmp_path / "out"), 1_000) + assert exc.value.code == "too_complex" + assert not (tmp_path / "out").exists() + + +def test_not_an_animated_gif_rejected(tmp_path): + path = tmp_path / "still.gif" + Image.new("RGB", (30, 30), "red").save(path, format="GIF") + with pytest.raises(GIFOptimizationError) as exc: + optimize_gif(str(path), str(tmp_path), 1) + assert exc.value.code == "not_animated" + + +def test_invalid_gif_does_not_write_candidate(tmp_path): + path = tmp_path / "bad.gif" + path.write_bytes(b"GIF89a" + b"x" * 5000) + with pytest.raises(GIFOptimizationError) as exc: + optimize_gif(str(path), str(tmp_path / "tmp"), 100) + assert exc.value.code == "invalid" + assert not (tmp_path / "tmp").exists() + + +def test_oversized_gif_rejected_pre_decode(tmp_path): + # Sparse test fixture: preflight must stop it before opening/allocating. + path = tmp_path / "huge.gif" + with open(path, "wb") as f: + f.truncate(MAX_SOURCE_BYTES + 1) + with pytest.raises(GIFOptimizationError) as exc: + optimize_gif(str(path), str(tmp_path / "tmp"), 100) + assert exc.value.code == "too_complex" + assert not (tmp_path / "tmp").exists() diff --git a/tests/test_gif_preview_api.py b/tests/test_gif_preview_api.py new file mode 100644 index 0000000..dcccb1a --- /dev/null +++ b/tests/test_gif_preview_api.py @@ -0,0 +1,167 @@ +"""Explicit GIF preview bridge with temporary files and source integrity guards.""" +from __future__ import annotations + +import hashlib +import os +import random +from pathlib import Path +from types import SimpleNamespace +from urllib.error import HTTPError +from urllib.request import urlopen + +import pytest +from PIL import Image + +from notro_app import config, fetch, resize +from notro_app.library import Library +from notro_app.picker.assets_server import AssetServer +from notro_app.picker.window import PickerApi + + +def gif_item(tmp_path): + rnd = random.Random(8) + src = tmp_path / "gif-source.gif" + frames = [ + Image.frombytes("RGB", (100, 90), rnd.randbytes(100 * 90 * 3)) + for _ in range(14) + ] + frames[0].save(src, format="GIF", save_all=True, + append_images=frames[1:], duration=80, loop=2) + library = Library(str(tmp_path / "library")) + item = fetch.register_from_file( + library, str(src), "gif", name="GIF before and after") + return library, item + + +def setup(tmp_path, monkeypatch): + library, item = gif_item(tmp_path) + source = Path(library.asset_path(item)) + digest = hashlib.sha256(source.read_bytes()).hexdigest() + monkeypatch.setattr(config, "LIMIT_BYTES", int(source.stat().st_size * .65)) + monkeypatch.setattr(config, "UPLOAD_CEILING_BYTES", + int(source.stat().st_size * .80)) + monkeypatch.setattr(config, "TEMP_DIR", str(tmp_path / "temporary")) + monkeypatch.setattr(resize, "target_px_for", lambda t: 0) + server = AssetServer( + lambda id_: library.asset_path(found) + if (found := library.resolve(id_)) else None, + ) + server.start() + api = PickerApi(library=library, asset_server=server) + return library, item, source, digest, server, api + + +def test_preview_is_animated_private_and_never_writes_library(tmp_path, monkeypatch): + library, item, source, digest, server, api = setup(tmp_path, monkeypatch) + try: + result = api.preview_gif(item["id"]) + assert result["ok"] and result["optimized_bytes"] < config.LIMIT_BYTES + assert result["frames"] == 14 + assert result["original_size"] == [100, 90] + assert len(library.items()) == 1 + assert hashlib.sha256(source.read_bytes()).hexdigest() == digest + with urlopen(result["optimized_url"], timeout=5) as resp: + assert resp.status == 200 + assert resp.headers["Content-Type"].startswith("image/gif") + assert len(resp.read()) == result["optimized_bytes"] + assert api.discard_gif_preview(result["token"]) + assert len(server._temporary) == 0 + with pytest.raises(HTTPError): + urlopen(result["optimized_url"], timeout=5) + finally: + server.stop() + + +def test_accept_pastes_prepared_candidate_without_library_change(tmp_path, monkeypatch): + library, item, source, digest, server, api = setup(tmp_path, monkeypatch) + calls = [] + api._ctrl = SimpleNamespace( + select=lambda item_id, mode="file", override_path=None: + calls.append((item_id, mode, override_path)) or True + ) + try: + result = api.preview_gif(item["id"]) + assert api.paste_gif_preview(item["id"], result["token"]) == {"ok": True} + assert len(calls) == 1 + assert calls[0][:2] == (item["id"], "optimized") + candidate_path = Path(calls[0][2]) + assert candidate_path.exists() + assert candidate_path.stat().st_size == result["optimized_bytes"] + assert len(library.items()) == 1 + assert hashlib.sha256(source.read_bytes()).hexdigest() == digest + assert not api.discard_gif_preview(result["token"]) + assert api.paste_gif_preview(item["id"], result["token"]) == { + "ok": False, "error": "outdated", + } + finally: + server.stop() + + +def test_mutated_original_blocks_accept_and_cleans_candidate(tmp_path, monkeypatch): + _, item, source, _, server, api = setup(tmp_path, monkeypatch) + try: + result = api.preview_gif(item["id"]) + candidate_path = Path(server._temporary[result["token"]]) + source.write_bytes(source.read_bytes() + b"x") + assert api.paste_gif_preview(item["id"], result["token"]) == { + "ok": False, "error": "outdated", + } + assert not candidate_path.exists() + finally: + server.stop() + + +def test_changed_limit_blocks_accept(tmp_path, monkeypatch): + _, item, _, _, server, api = setup(tmp_path, monkeypatch) + try: + result = api.preview_gif(item["id"]) + monkeypatch.setattr(config, "LIMIT_BYTES", config.LIMIT_BYTES + 1) + assert api.paste_gif_preview(item["id"], result["token"]) == { + "ok": False, "error": "outdated", + } + finally: + server.stop() + + +def test_cancellation_does_not_remove_original_or_enter_clipboard(tmp_path, monkeypatch): + _, item, source, digest, server, api = setup(tmp_path, monkeypatch) + try: + result = api.preview_gif(item["id"]) + candidate = Path(server._temporary[result["token"]]) + assert api.discard_gif_preview(result["token"]) + assert not candidate.exists() + assert source.exists() + assert hashlib.sha256(source.read_bytes()).hexdigest() == digest + finally: + server.stop() + + +def test_unsupported_and_fitting_gif_are_safe(tmp_path, monkeypatch): + _, item, source, _, server, api = setup(tmp_path, monkeypatch) + try: + assert api.preview_gif("not-an-item") == { + "ok": False, "error": "unsupported", + } + monkeypatch.setattr(config, "LIMIT_BYTES", os.stat(source).st_size * 2) + assert api.preview_gif(item["id"]) == { + "ok": False, "error": "already_fits", + } + finally: + server.stop() + + +def test_gif_between_safety_target_and_actual_ceiling_needs_no_preview( + tmp_path, monkeypatch): + _, item, source, _, server, api = setup(tmp_path, monkeypatch) + try: + # Between compression target and maximum upload threshold. + monkeypatch.setattr(config, "LIMIT_BYTES", + int(source.stat().st_size * .65)) + monkeypatch.setattr(config, "UPLOAD_CEILING_BYTES", + source.stat().st_size + 10) + assert api.preview_gif(item["id"]) == { + "ok": False, "error": "already_fits", + } + assert not server._temporary + finally: + server.stop() diff --git a/tests/test_paste_safety.py b/tests/test_paste_safety.py index 5141599..bf8e24b 100644 --- a/tests/test_paste_safety.py +++ b/tests/test_paste_safety.py @@ -146,3 +146,14 @@ def test_link_mode_pastes_but_never_auto_sends(monkeypatch): assert calls == ["paste"] assert lib.used == ["fixture"] assert not msgs + + +def test_optimized_preview_paste_never_auto_sends_even_when_enabled(monkeypatch): + picker, lib, calls, msgs = prepare( + monkeypatch, autosend=True, target_is_discord=True, focused=(True,)) + assert picker.select( + "fixture", mode="optimized", + override_path=r"C:\fixture\optimized.gif") is True + assert calls == ["paste"] + assert lib.used == ["fixture"] + assert not msgs diff --git a/tests/test_picker_ui_contract.py b/tests/test_picker_ui_contract.py index f1c901c..3fabb33 100644 --- a/tests/test_picker_ui_contract.py +++ b/tests/test_picker_ui_contract.py @@ -110,3 +110,19 @@ def test_zip_drag_drop_reuses_pack_import_api(): assert "paths.length === 1" in js assert 'api().import_pack_path(paths[0])' in js assert 'flashHint("picker_pack_error")' in js + + +def test_gif_comparison_requires_explicit_acceptance_and_supports_cancel(): + by_id = {attrs.get("id"): (tag, attrs) for tag, attrs in elements() + if attrs.get("id")} + assert by_id["modal-gif"][0] == "div" + assert by_id["gif-source-image"][0] == "img" + assert by_id["gif-result-image"][0] == "img" + accept_tag, accept = by_id["gif-accept"] + assert accept_tag == "button" and "disabled" in accept + assert by_id["gif-cancel"][0] == "button" + js = (UI_DIR / "app.js").read_text(encoding="utf-8") + assert "api().preview_gif(item.id)" in js + assert "api().paste_gif_preview(candidate.itemId, candidate.token)" in js + assert "api().discard_gif_preview(candidate.token)" in js + assert "item.animated" in js From 668b117a3d74a6f7b2d275ccba73876ecc21e737 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 18:02:59 -0700 Subject: [PATCH 08/33] ci(notro): retain short-lived unsigned installer for manual QA --- .github/workflows/release.yml | 14 ++++++++++++++ CHANGELOG.md | 3 +++ docs/qa/2026-10-13-notro-readiness.md | 9 +++++++++ tests/test_release_version_check.py | 14 ++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03e7a39..a846d23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,20 @@ jobs: $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 2a13d36..63019d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). 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. +- 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. diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index 068570a..39d98a9 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -148,3 +148,12 @@ Portfolio PR #36 passes type/lint/build/tests but the dependency audit fails wit 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. diff --git a/tests/test_release_version_check.py b/tests/test_release_version_check.py index 2fb8e6a..12d55ea 100644 --- a/tests/test_release_version_check.py +++ b/tests/test_release_version_check.py @@ -200,3 +200,17 @@ def test_release_workflow_uses_node_24_release_action_major(): assert re.findall(r"softprops/action-gh-release@\S+", workflow) == [ "softprops/action-gh-release@v3" ] + + +def test_manual_release_run_uploads_short_lived_unsigned_qa_artifact(): + workflow = _workflow(RELEASE_WORKFLOW) + step = _step(workflow, "Upload unsigned QA installer") + assert "if: github.event_name == 'workflow_dispatch'" in step + assert "actions/upload-artifact@v7" in step + assert "NOTRO-UNSIGNED-QA-" in step + assert "retention-days: 3" in step + assert "if-no-files-found: error" in step + assert "overwrite: false" in step + assert "dist/NotroSetup.exe.sha256" in step + assert "dist/NotroSetup.exe" in step + assert PUSH_TAG_GUARD in _step(workflow, "Publish release") From 469de535191a3b00c259af9fff2ed44067b05878 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 18:06:40 -0700 Subject: [PATCH 09/33] docs(notro): record verified unsigned QA installer provenance --- docs/qa/2026-10-13-notro-readiness.md | 20 ++++++++++++++++++++ scripts/plane_sync.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index 39d98a9..58f2276 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -157,3 +157,23 @@ Manual workflow_dispatch now uploads NotroSetup.exe and its SHA-256 as a 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. diff --git a/scripts/plane_sync.py b/scripts/plane_sync.py index 8599bf5..8b1cff1 100644 --- a/scripts/plane_sync.py +++ b/scripts/plane_sync.py @@ -37,7 +37,7 @@ ("Windows real-app picker and clipboard E2E smoke QA", "To Do", "urgent", "Run Discord test in a private draft (no unwanted message sent), exercise PNG/JPEG/GIF, hotkeys, source file preservation, auto-send off, keyboard use, DPI/multi-monitor, failure recovery."), ("Notro trust and distribution: signing and pinned builds", "To Do", "high", - "Candidate v2.11.0 CI and workflow_dispatch dry-run packaging succeeded (EXE, Inno Setup, SHA256); no public tag/release. Remaining: OSS signing routes, provenance, dependency security and real SmartScreen/install QA."), + "Candidate v2.11.0 manual build 38493606960 passed (EXE, Inno, SHA256, 3-day UNSIGNED QA artifact); @4080 independently verified NotroSetup.exe bytes=17090810 and SHA256=48244c04e5ca765fe8683565a1d3a8474ca98018e04ddb511b4dc7cf25e9d0ef, FileVersion/ProductVersion=2.11.0, Authenticode=NotSigned. Installer was NOT executed; no public release/tag. Signing, SmartScreen, runtime install, supply-chain verification remain open."), ("First-run activation: 3-minute task-guided welcome", "QA", "high", "First-run Try Picker action implemented and localized. Confirm 3-minute human first-paste usability on Windows WebView2 before Done."), ("Shareable safe emoji pack import/export and library backup", "QA", "high", From b0045e830e40c584494553630e908898bb509c32 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 19:08:19 -0700 Subject: [PATCH 10/33] test(notro): exercise real Windows file dialogs and 5k WebView gallery --- CHANGELOG.md | 6 + docs/qa/2026-10-13-notro-readiness.md | 26 +++ notro_app/picker/window.py | 25 ++- scripts/plane_sync.py | 4 + scripts/qa_native_file_dialogs.py | 228 ++++++++++++++++++++++++++ scripts/qa_native_webview.py | 42 ++++- tests/test_packs.py | 25 +++ 7 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 scripts/qa_native_file_dialogs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63019d4..0f87dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,12 @@ This project adheres to [Semantic Versioning](https://semver.org/). import using synthetic data, without opening chats or modifying Discord. - 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. diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index 58f2276..062ec59 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -177,3 +177,29 @@ Real installer, SmartScreen and Discord E2E tests remain open. 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. diff --git a/notro_app/picker/window.py b/notro_app/picker/window.py index 01729b2..e724a69 100644 --- a/notro_app/picker/window.py +++ b/notro_app/picker/window.py @@ -227,6 +227,22 @@ def _collection(name) -> str: return "" if name in ("__all__", "__fav__") else name +def _native_dialog_kind(name: str, module=None): + """Prefer current pywebview FileDialog enum, fallback without eager warning. + + Evaluating the old SAVE_DIALOG/OPEN_DIALOG attributes even as a fallback + argument raises a deprecation warning on pywebview 6.x. + """ + if module is None: + import webview as module + + kinds = getattr(module, "FileDialog", None) + modern = getattr(kinds, name, None) if kinds is not None else None + if modern is not None: + return modern + return getattr(module, name + "_DIALOG") + + class PickerApi(GifPreviewMixin): """JS 브리지. 각 메서드는 pywebview API 스레드에서 호출된다. @@ -368,8 +384,7 @@ def export_pack(self) -> dict: import webview from .. import packs - dialog = getattr(getattr(webview, "FileDialog", None), "SAVE", - webview.SAVE_DIALOG) + dialog = _native_dialog_kind("SAVE", webview) chosen = self._ctrl.window.create_file_dialog( dialog, save_filename="notro-pack.zip", file_types=("ZIP archives (*.zip)",)) @@ -386,8 +401,7 @@ def import_pack(self) -> dict: """Explicit dialog; preflight ZIP before library mutations.""" import webview - dialog = getattr(getattr(webview, "FileDialog", None), "OPEN", - webview.OPEN_DIALOG) + dialog = _native_dialog_kind("OPEN", webview) chosen = self._ctrl.window.create_file_dialog( dialog, file_types=("ZIP archives (*.zip)",)) if not chosen: @@ -430,7 +444,8 @@ def register_clipboard(self, type_: str, collection: str = "") -> dict: def add_folder(self, default_type: str = "gif") -> dict: import webview - res = self._ctrl.window.create_file_dialog(webview.FOLDER_DIALOG) + res = self._ctrl.window.create_file_dialog( + _native_dialog_kind("FOLDER", webview)) if res: self._library.add_folder(res[0], default_type) return {"ok": True} diff --git a/scripts/plane_sync.py b/scripts/plane_sync.py index 8b1cff1..3d58751 100644 --- a/scripts/plane_sync.py +++ b/scripts/plane_sync.py @@ -32,6 +32,10 @@ "Implemented HWND focus recheck before Ctrl+V; optional Enter only to still-focused Discord.exe/PTB/Canary via public Win32 process filename APIs. Auto-send off by default; read-only current Discord HWND identity passed; unit tests passed. Live unsent Discord draft interaction remains unverified."), ("Native isolated WebView2 picker and five-language fixture smoke", "Done", "high", "Native Windows Edge WebView2 launched against temporary owned PNG/JPEG assets, with no Discord process changes or global clipboard access. 2 assets and thumbnails loaded, 3 tabs, pack buttons, safe ZIP drop deduped, search and KO/EN/JA/ZH/ES Python-to-JS bridge passed; process exited without errors."), + ("Native Windows file-dialog export/import E2E (Korean ZIP)", "Done", "high", + "Executed scripts/qa_native_file_dialogs.py on @4080 using real Win32 Save As/Open dialogs and isolated native WebView2 bridge. Four stages PASS: cancel export, save Hangul-named ZIP, cancel import, import into a fresh temporary library. Original digest preserved. No Discord/clipboard/user data touched. Installer and actual Discord attachment QA separate."), + ("Native WebView2 synthetic 100/1k/5k picker DOM benchmark", "Done", "medium", + "Executed scripts/qa_native_webview.py on @4080 using real Windows WebView2 with synthetic 1x1 placeholders. 100 items render 1ms/search 0ms; 1000 items 8ms/search 1ms; 5000 items 39ms/search 4ms. All 5000 DOM cells created, zero errors. Real mixed-size GIF/WebView2 memory QA still required."), ("Local library asset server host/token access protection", "QA", "high", "Added per-instance random capability path and exact 127.0.0.1:port Host enforcement (DNS rebinding defense in depth), bounded streaming and HTTP regression tests. Confirmed actual WebView2 thumbnails load. Extended security/disclosure documentation."), ("Windows real-app picker and clipboard E2E smoke QA", "To Do", "urgent", diff --git a/scripts/qa_native_file_dialogs.py b/scripts/qa_native_file_dialogs.py new file mode 100644 index 0000000..497156b --- /dev/null +++ b/scripts/qa_native_file_dialogs.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Real Windows file-dialog E2E for Notro's own isolated WebView2 test window. + +Covers cancel, Save a Unicode ZIP pack, cancel Import, and Import the saved +pack into a fresh temporary library. Dialog automation touches ONLY the +#32770 window of this exact Python process, never Discord, real clipboard, +registry, settings or personal Notro libraries. +""" +from __future__ import annotations + +import hashlib +import json +import os +import sys +import tempfile +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from notro_app import fetch +from notro_app.library import Library +from notro_app.picker.window import PickerApi + +DIALOG_TIMEOUT = 16 +BRIDGE_TIMEOUT = 20 + + +def find_owned_dialog() -> int | None: + """Exactly process-owned, visible common dialog; never inspect other apps.""" + import win32gui + import win32process + + own = [] + + def callback(hwnd, _): + if ( + win32process.GetWindowThreadProcessId(hwnd)[1] == os.getpid() + and win32gui.IsWindowVisible(hwnd) + and win32gui.GetClassName(hwnd) == "#32770" + ): + own.append(hwnd) + + win32gui.EnumWindows(callback, None) + return own[0] if len(own) == 1 else None + + +def act_on_dialog(action: str, path: str | None, result: dict) -> None: + """Drive Win32 file dialog without changing keyboard focus globally.""" + import win32con + import win32gui + from pywinauto import Desktop + + deadline = time.monotonic() + DIALOG_TIMEOUT + while time.monotonic() < deadline: + hwnd = find_owned_dialog() + if hwnd is not None: + result["seen"] = True + dlg = Desktop(backend="win32").window(handle=hwnd) + try: + if action == "cancel": + win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0) + result["action"] = "cancelled" + return + assert path, "path required for file dialog accept" + edits = dlg.descendants(class_name="Edit") + # Native Windows dialogs may expose a second Edit field for + # search/address; the filename Edit is the one with the + # ordinary FileNameEdit / COMDLG32 edit class and at the + # beginning of the standard dialog content hierarchy. + if not edits: + raise RuntimeError("no native filename edit control") + filename_edits = [e for e in edits if e.window_text().startswith( + ("notro-pack", "한글 파일", "테스트", "*.zip"))] + file_edit = filename_edits[0] if filename_edits else edits[0] + result["edits"] = [(e.window_text()[:35],e.control_id()) + for e in edits[:5]] + file_edit.set_edit_text(path) + buttons = dlg.descendants(class_name="Button") + matches = [ + button for button in buttons + if button.window_text().casefold().startswith( + ("save", "open", "저장", "열기") + ) + ] + if len(matches) != 1: + raise RuntimeError( + "expected one Save/Open button, got " + repr( + [b.window_text() for b in buttons][:14] + ) + ) + matches[0].click() + result["action"] = "confirmed" + return + except Exception as exc: + result["error"] = repr(exc)[:300] + win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0) + return + time.sleep(0.15) + result["error"] = "no own native file dialog within timeout" + + +def main() -> int: + import webview + + with tempfile.TemporaryDirectory(prefix="notro-real-dialog-") as temp: + root = Path(temp) + source = root / "테스트 이미지.png" + Image.new("RGBA", (20, 20), (50, 125, 200, 255)).save(source) + original_library = Library(str(root / "original")) + fetch.register_from_file( + original_library, str(source), "emoji", name="테스트 이미지" + ) + original_digest = hashlib.sha256(source.read_bytes()).hexdigest() + dest_pack = root / "한글 파일 이름 테스트.zip" + + api = PickerApi(library=original_library) + win = webview.create_window( + "Notro QA native file dialog isolated", + html="Notro native dialog QA", + js_api=api, width=470, height=260, on_top=False, + ) + api._ctrl = SimpleNamespace(window=win) + final = {"stages": {}, "source_preserved": False, + "imported": False, "errors": []} + + def run_stage(stage: str, api_method: str, + action: str, file_path: str | None): + result = {"seen": False, "action": None} + watcher = threading.Thread( + target=act_on_dialog, args=(action, file_path, result), daemon=True + ) + watcher.start() + # JS calls the real Python bridge. No mocked file-dialog output. + js = ( + "document.body.textContent='pending';" + f"window.pywebview.api.{api_method}()" + ".then(r=>document.body.textContent=JSON.stringify(r))" + ".catch(e=>document.body.textContent=JSON.stringify({error:String(e)}))" + ) + win.evaluate_js(js) + answer = None + deadline = time.monotonic() + BRIDGE_TIMEOUT + while time.monotonic() < deadline: + value = win.evaluate_js("document.body.textContent").strip() + if value.startswith("{"): + answer = json.loads(value) + break + time.sleep(0.15) + watcher.join(timeout=1) + if watcher.is_alive(): + result["error"] = "dialog watcher still running" + result["reply"] = answer + final["stages"][stage] = result + return answer + + def execute(): + try: + for _ in range(40): + if win.evaluate_js( + "Boolean(window.pywebview && window.pywebview.api)" + ): + break + time.sleep(0.15) + + cancel_export = run_stage("save_cancel", "export_pack", "cancel", None) + assert cancel_export == {"ok": False, "error": "cancelled"}, cancel_export + assert not dest_pack.exists() + + exported = run_stage( + "save_unicode", "export_pack", "confirm", str(dest_pack) + ) + assert exported == {"ok": True, "count": 1}, exported + assert dest_pack.is_file() + final["pack_bytes"] = dest_pack.stat().st_size + + target_library = Library(str(root / "imported")) + api._library = target_library + cancel_import = run_stage("open_cancel", "import_pack", "cancel", None) + assert cancel_import == {"ok": False, "error": "cancelled"}, cancel_import + assert target_library.items() == [] + + imported = run_stage( + "open_unicode", "import_pack", "confirm", str(dest_pack) + ) + assert imported == { + "ok": True, "added": 1, "duplicate": 0, "failed": 0 + }, imported + final["imported"] = ( + len(target_library.items()) == 1 + and target_library.items()[0]["name"] == "테스트 이미지" + ) + final["source_preserved"] = ( + hashlib.sha256(source.read_bytes()).hexdigest() == original_digest + ) + except Exception as exc: + final["errors"].append(type(exc).__name__ + ": " + str(exc)[:300]) + finally: + hwnd = find_owned_dialog() + if hwnd is not None: + import win32con + import win32gui + win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0) + win.destroy() + + webview.start(execute, gui="edgechromium", http_server=False) + final["passed"] = ( + not final["errors"] + and final["source_preserved"] and final["imported"] + and all( + stage.get("seen") and not stage.get("error") + for stage in final["stages"].values() + ) + and len(final["stages"]) == 4 + ) + print( + "NOTRO_NATIVE_FILE_DIALOG_QA=" + json.dumps(final, ensure_ascii=True), + flush=True + ) + return 0 if final["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qa_native_webview.py b/scripts/qa_native_webview.py index 123c5b5..0574ef9 100644 --- a/scripts/qa_native_webview.py +++ b/scripts/qa_native_webview.py @@ -80,7 +80,8 @@ def main() -> int: "asset_images_loaded": False, "locale_labels": [], "zip_drop": False, "gif_preview": False, "gif_source_preserved": False, "gif_cancelled": False, - "gif_mock_accept": False} + "gif_mock_accept": False, + "gallery_5k": False, "gallery_benchmarks": []} errors = [] def examine(): @@ -222,6 +223,43 @@ def examine(): and calls[0][1] == "optimized" and calls[0][2] and Path(calls[0][2]).is_file() ) + # Test the real WebView2 renderer, not just the Python + # filesystem scanner. All 5,000 items are synthetic and + # use a tiny 1x1 data URL, never private media. + win.evaluate_js("window.__fixtureItems=state.items;") + for n in (100, 1000, 5000): + script = """(() => { + const n = __N__; + const img = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + state.items = Array.from({length:n}, (_,i) => ({ + id: String(i), type: 'emoji', name: 'sample-' + i, + keywords: [], url: img, animated:false, + convert_warning:false, favorite:false, + collection:'', is_folder:false, can_url:false + })); + state.tab = 'emoji'; state.query = ''; + state.collection = '__all__'; state.recent = []; + let t = performance.now(); render(); + const render_ms = Math.round(performance.now()-t); + const displayed = document.querySelectorAll('.cell').length; + state.query='sample-' + Math.min(n-1, 499); t=performance.now(); render(); + return {items:n,render_ms, + search_ms: Math.round(performance.now()-t), + dom_cells:displayed, + filtered_cells:document.querySelectorAll('.cell').length}; + })()""".replace("__N__", str(n)) + record = win.evaluate_js(script) + result["gallery_benchmarks"].append(record) + result["gallery_5k"] = all( + r["dom_cells"] == r["items"] + and r["render_ms"] < 3000 + and r["filtered_cells"] >= 1 + for r in result["gallery_benchmarks"] + ) + win.evaluate_js( + "state.items=window.__fixtureItems;" + "state.query='';render();" + ) except Exception as e: errors.append(type(e).__name__ + ": " + str(e)[:300]) finally: @@ -244,7 +282,7 @@ def examine(): and len(result["locale_labels"]) == 5 and result["zip_drop"] and result["gif_preview"] and result["gif_cancelled"] and result["gif_source_preserved"] and result["gif_mock_accept"] - and not errors + and result["gallery_5k"] and not errors ) print("NOTRO_NATIVE_QA=" + json.dumps(result, ensure_ascii=True), flush=True) return 0 if result["passed"] else 1 diff --git a/tests/test_packs.py b/tests/test_packs.py index e4fbb32..da0c39c 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -179,3 +179,28 @@ def test_drag_drop_pack_reuses_same_safe_import_bridge(tmp_path): assert api.import_pack_path(str(tmp_path / "not-a-pack.txt")) == { "ok": False, "error": "pack", } + + +def test_dialog_type_prefers_modern_enum_without_touching_deprecated_alias(): + from types import SimpleNamespace + from notro_app.picker.window import _native_dialog_kind + + class ModernOnly: + FileDialog = SimpleNamespace(OPEN=10, SAVE=30, FOLDER=20) + + def __getattr__(self, key): + raise AssertionError("unexpected deprecated enum attribute: " + key) + + m = ModernOnly() + assert _native_dialog_kind("OPEN", m) == 10 + assert _native_dialog_kind("SAVE", m) == 30 + assert _native_dialog_kind("FOLDER", m) == 20 + + +def test_dialog_type_accepts_old_pywebview_without_modern_enum(): + from types import SimpleNamespace + from notro_app.picker.window import _native_dialog_kind + + legacy = SimpleNamespace(OPEN_DIALOG=10, SAVE_DIALOG=30, FOLDER_DIALOG=20) + for name, value in (("OPEN", 10), ("SAVE", 30), ("FOLDER", 20)): + assert _native_dialog_kind(name, legacy) == value From e26122bc130cdc0bbea81f9e77aabab985b24395 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 19:10:04 -0700 Subject: [PATCH 11/33] docs(notro): keep Discord draft E2E open after native Windows QA --- docs/qa/2026-10-13-notro-readiness.md | 11 +++++++++++ scripts/plane_sync.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index 062ec59..9669ca9 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -203,3 +203,14 @@ Real installer, SmartScreen and Discord E2E tests remain open. - 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. diff --git a/scripts/plane_sync.py b/scripts/plane_sync.py index 3d58751..5538ac5 100644 --- a/scripts/plane_sync.py +++ b/scripts/plane_sync.py @@ -38,8 +38,8 @@ "Executed scripts/qa_native_webview.py on @4080 using real Windows WebView2 with synthetic 1x1 placeholders. 100 items render 1ms/search 0ms; 1000 items 8ms/search 1ms; 5000 items 39ms/search 4ms. All 5000 DOM cells created, zero errors. Real mixed-size GIF/WebView2 memory QA still required."), ("Local library asset server host/token access protection", "QA", "high", "Added per-instance random capability path and exact 127.0.0.1:port Host enforcement (DNS rebinding defense in depth), bounded streaming and HTTP regression tests. Confirmed actual WebView2 thumbnails load. Extended security/disclosure documentation."), - ("Windows real-app picker and clipboard E2E smoke QA", "To Do", "urgent", - "Run Discord test in a private draft (no unwanted message sent), exercise PNG/JPEG/GIF, hotkeys, source file preservation, auto-send off, keyboard use, DPI/multi-monitor, failure recovery."), + ("Windows real-app picker and clipboard E2E smoke QA", "In Progress", "urgent", + "Windows actual WebView2 picker smoke, Korean real Save/Open dialogs and synthetic 5000-item DOM benchmarks PASS. Remaining: safe Discord unpublished draft Ctrl+V with PNG/JPG/GIF and 19.5/24MB thresholds, focus hotkeys, manual clipboard-preservation review, per-monitor DPI and installer runtime. Do not auto-send chat or access account/private messages during automated QA."), ("Notro trust and distribution: signing and pinned builds", "To Do", "high", "Candidate v2.11.0 manual build 38493606960 passed (EXE, Inno, SHA256, 3-day UNSIGNED QA artifact); @4080 independently verified NotroSetup.exe bytes=17090810 and SHA256=48244c04e5ca765fe8683565a1d3a8474ca98018e04ddb511b4dc7cf25e9d0ef, FileVersion/ProductVersion=2.11.0, Authenticode=NotSigned. Installer was NOT executed; no public release/tag. Signing, SmartScreen, runtime install, supply-chain verification remain open."), ("First-run activation: 3-minute task-guided welcome", "QA", "high", From 4ecf7bca4436ebff8532891fed2948b3be81ce33 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 13 Oct 2026 19:15:30 -0700 Subject: [PATCH 12/33] docs(notro): record secure signing options and main-branch gate --- CODE_SIGNING.md | 13 +++ README.md | 2 +- docs/qa/2026-10-13-notro-readiness.md | 16 ++++ ...2026-10-13-notro-code-signing-readiness.md | 91 +++++++++++++++++++ scripts/plane_sync.py | 4 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/research/2026-10-13-notro-code-signing-readiness.md diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md index 77afbd3..9e8d9ad 100644 --- a/CODE_SIGNING.md +++ b/CODE_SIGNING.md @@ -31,6 +31,19 @@ Notro is maintained by one person, who therefore holds all three roles: Multi-factor authentication is required on the GitHub account and on the signing service account. +## Signing readiness and source protection + +As of October 13, 2026, authenticated GitHub API inspection shows the +repository main branch is not protected. Before requesting an OSS signing +certificate, establish required Windows CI and release review rules +after checking concurrent worktrees and available human reviewers. +A maintainer must not sign unreviewed artifacts through an automatic agent. +No SignPath Foundation certificate has been approved or attached. + +For verified unsigned build hashes, OSS signing requirements, Azure +alternatives and remaining blockers, see +[the October 2026 signing note](docs/research/2026-10-13-notro-code-signing-readiness.md). + ## How releases are built Releases are built only by GitHub Actions from a tagged commit on `main` diff --git a/README.md b/README.md index de73b01..e72b778 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,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 diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index 9669ca9..f1efa01 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -214,3 +214,19 @@ 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. 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/scripts/plane_sync.py b/scripts/plane_sync.py index 5538ac5..e432077 100644 --- a/scripts/plane_sync.py +++ b/scripts/plane_sync.py @@ -40,8 +40,10 @@ "Added per-instance random capability path and exact 127.0.0.1:port Host enforcement (DNS rebinding defense in depth), bounded streaming and HTTP regression tests. Confirmed actual WebView2 thumbnails load. Extended security/disclosure documentation."), ("Windows real-app picker and clipboard E2E smoke QA", "In Progress", "urgent", "Windows actual WebView2 picker smoke, Korean real Save/Open dialogs and synthetic 5000-item DOM benchmarks PASS. Remaining: safe Discord unpublished draft Ctrl+V with PNG/JPG/GIF and 19.5/24MB thresholds, focus hotkeys, manual clipboard-preservation review, per-monitor DPI and installer runtime. Do not auto-send chat or access account/private messages during automated QA."), + ("Harden Notro main branch review/CI rules before code signing", "To Do", "urgent", + "Authenticated GitHub API check on 2026-10-13 found main branch not protected (HTTP 404). Coordinate other coding sessions/reviewers before adding branch protection or rulesets. Target required Windows CI, protected release tags, minimum PR review and ownership for release approval. Do not automatically lock main or break ongoing merges; signing approval must remain with maintainer."), ("Notro trust and distribution: signing and pinned builds", "To Do", "high", - "Candidate v2.11.0 manual build 38493606960 passed (EXE, Inno, SHA256, 3-day UNSIGNED QA artifact); @4080 independently verified NotroSetup.exe bytes=17090810 and SHA256=48244c04e5ca765fe8683565a1d3a8474ca98018e04ddb511b4dc7cf25e9d0ef, FileVersion/ProductVersion=2.11.0, Authenticode=NotSigned. Installer was NOT executed; no public release/tag. Signing, SmartScreen, runtime install, supply-chain verification remain open."), + "Source e26122b manual run 38499645717: Windows tests, EXE, Inno, SHA256 and 3-day QA artifact PASS, publication SKIPPED. Independently verified NotroSetup.exe size 17091762, checksum 1b91f47abce7daf9d3f0a772cba065ba951f0885e22d5557241662ba9855ba9a, VersionInfo 2.11.0, Authenticode NotSigned; file NOT executed. SignPath Foundation free OSS requires maintained released app, reputation, signing review and provenance; Azure Artifact Signing individual US/Canada roughly USD9.99/month with identity verification. Neither account/cert enrolled; see docs/research/2026-10-13-notro-code-signing-readiness.md. Main currently unprotected; SmartScreen/real install pending."), ("First-run activation: 3-minute task-guided welcome", "QA", "high", "First-run Try Picker action implemented and localized. Confirm 3-minute human first-paste usability on Windows WebView2 before Done."), ("Shareable safe emoji pack import/export and library backup", "QA", "high", From 43275b3704df199719b4f1ee11967f0fb8e67685 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Wed, 14 Oct 2026 14:16:44 -0700 Subject: [PATCH 13/33] feat(notro): opt-in settings backup and reviewed restore without signing --- CHANGELOG.md | 5 + README.es.md | 13 ++ README.ja.md | 12 ++ README.ko.md | 13 ++ README.md | 14 ++ README.zh.md | 11 ++ SECURITY.md | 9 + docs/qa/2026-10-13-notro-readiness.md | 26 +++ notro_app/i18n.py | 91 ++++++++++ notro_app/picker/ui/app.css | 7 + notro_app/picker/ui/app.js | 101 +++++++++++- notro_app/picker/ui/index.html | 27 +++ notro_app/picker/window.py | 93 +++++++++++ notro_app/settings_backup.py | 229 ++++++++++++++++++++++++++ scripts/plane_sync.py | 14 +- scripts/qa_native_file_dialogs.py | 74 ++++++++- scripts/qa_native_webview.py | 16 +- tests/test_picker_ui_contract.py | 19 +++ tests/test_settings_backup.py | 143 ++++++++++++++++ tests/test_settings_bridge.py | 115 +++++++++++++ 20 files changed, 1015 insertions(+), 17 deletions(-) create mode 100644 notro_app/settings_backup.py create mode 100644 tests/test_settings_backup.py create mode 100644 tests/test_settings_bridge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f87dc6..06abab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). 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. diff --git a/README.es.md b/README.es.md index 0883eb7..6b18477 100644 --- a/README.es.md +++ b/README.es.md @@ -108,6 +108,19 @@ 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 diff --git a/README.ja.md b/README.ja.md index ad9d4c0..4b88213 100644 --- a/README.ja.md +++ b/README.ja.md @@ -100,6 +100,18 @@ Discord が課金対象にしている 2 つのことを、アプリの外側か パックの ZIP をピッカーへ直接ドロップして読み込めます。設定メニューからの読み込みと同じ検証を行います。 +## 設定バックアップ・復元(JSON) + +ピッカーの設定から言語、ショートカット、容量上限や表示サイズを +JSON に保存できます。画像や個人キャプチャ、Discord アカウント情報は +含まれません。監視フォルダーの絶対パスは初期状態で除外されます。 + +復元前に項目数を確認し、別の承認ボタンを押して適用します。 +既存フォルダーは削除せず、存在するパスのみ任意で追加できます。 +自動送信・自動キャプチャは復元後もオフで、Windows 起動設定は +変更しません。言語とショートカットには再起動が必要です。 +画像は別のメディアパック ZIP で移行してください。 + ## 自動圧縮 Notro はクリップボードを見張っています。新しい画像が現れると **Discord が実際に生成する diff --git a/README.ko.md b/README.ko.md index aa3fdda..12bd16c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -99,6 +99,19 @@ 가져올 팩 ZIP 파일을 선택기 위로 끌어다 놓아도 됩니다. 설정의 팩 가져오기 버튼과 동일한 검증을 거칩니다. +## 환경설정 백업·복원 (JSON) + +피커 설정의 **설정 백업**에서 언어·단축키·업로드 한도·선택기 및 붙여넣기 +크기를 JSON으로 저장합니다. 이미지·클립보드 기록·개인 캡처·Discord 계정 +데이터는 포함하지 않습니다. **감시 폴더 절대경로는 기본 제외**이며 +체크한 경우에만 저장합니다. + +**설정 복원…**은 설정과 폴더 개수를 미리 보여주고 별도 승인 후 +적용합니다. 기존 폴더를 삭제하지 않고 실제로 존재하는 경로만 +선택적으로 추가합니다. **자동 전송과 자동 캡처는 복원 후에도 꺼지며** +Windows 시작 프로그램 설정은 유지됩니다. 언어와 단축키 적용에는 +Notro 재시작이 필요합니다. 이미지는 별도 미디어 팩 ZIP으로 옮기세요. + ## 자동 압축 Notro는 클립보드를 지켜봅니다. 새 이미지가 들어오면 **디스코드가 실제로 만들어 낼 PNG diff --git a/README.md b/README.md index e72b778..1b6fee5 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,20 @@ 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 diff --git a/README.zh.md b/README.zh.md index 474e0b9..990ef89 100644 --- a/README.zh.md +++ b/README.zh.md @@ -93,6 +93,17 @@ ZIP 并非完整设置备份。仅分享您有权分发的媒体。 也可以把资源包 ZIP 直接拖放到选择器中,使用与设置中的“导入资源包”相同的安全验证。 +## 设置备份与恢复(JSON) + +选择器可将语言、快捷键、上传大小与显示尺寸备份为 JSON。 +不含图片、私人截图、剪贴板历史或 Discord 账户信息。 +监视文件夹绝对路径默认排除,只有主动勾选才包含。 + +恢复前会显示设置与路径数量,并要求再次确认。不会删除现有 +文件夹,只能选择添加存在的路径。自动发送与自动截图保存 +在恢复后保持关闭,Windows 开机启动状态不变。 +语言和快捷键需要重启 Notro;图片仍需另用媒体 ZIP 迁移。 + ## 自动压缩 Notro 会监视剪贴板。出现新图片时,它计算 **Discord 实际会生成的 PNG 体积**,如果在上限之内 diff --git a/SECURITY.md b/SECURITY.md index 54604ef..48a69b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -58,6 +58,15 @@ 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. +## 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 | Path | Contents | diff --git a/docs/qa/2026-10-13-notro-readiness.md b/docs/qa/2026-10-13-notro-readiness.md index f1efa01..21ba331 100644 --- a/docs/qa/2026-10-13-notro-readiness.md +++ b/docs/qa/2026-10-13-notro-readiness.md @@ -230,3 +230,29 @@ priority and ownership when refreshing issue descriptions. 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/notro_app/i18n.py b/notro_app/i18n.py index 3baa3e8..c0ebb7f 100644 --- a/notro_app/i18n.py +++ b/notro_app/i18n.py @@ -920,3 +920,94 @@ def tr(key: str, **kwargs) -> str: 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 diff --git a/notro_app/picker/ui/app.css b/notro_app/picker/ui/app.css index d7feda5..576b6d5 100644 --- a/notro_app/picker/ui/app.css +++ b/notro_app/picker/ui/app.css @@ -226,3 +226,10 @@ button.wide:hover { background: var(--accent); color: #fff; } } #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 611569d..c58773d 100644 --- a/notro_app/picker/ui/app.js +++ b/notro_app/picker/ui/app.js @@ -75,6 +75,15 @@ function applyStrings() { $("#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"); @@ -309,7 +318,8 @@ function hideCtx() { $("#ctx").classList.add("hidden"); } /* 열려 있는 창 하나 (없으면 undefined). 방향키·붙여넣기 처리는 창이 열려 있는 동안 그리드를 건드리면 안 된다. */ function openModal() { - return [$("#modal-add"), $("#modal-edit"), $("#modal-settings"), $("#modal-gif")] + return [$("#modal-add"), $("#modal-edit"), $("#modal-settings"), + $("#modal-gif"), $("#modal-profile")] .find((m) => !m.classList.contains("hidden")); } @@ -573,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) => @@ -611,18 +694,18 @@ $("#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 api().import_pack(); + 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 api().export_pack(); + 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"); }); @@ -637,6 +720,7 @@ 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; } @@ -649,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; @@ -713,6 +799,11 @@ 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. diff --git a/notro_app/picker/ui/index.html b/notro_app/picker/ui/index.html index 3eaff52..04741f9 100644 --- a/notro_app/picker/ui/index.html +++ b/notro_app/picker/ui/index.html @@ -86,6 +86,15 @@

    +

    + +
    + + +
    @@ -94,6 +103,24 @@

    + +