diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml new file mode 100644 index 0000000..cf5031f --- /dev/null +++ b/.github/workflows/bundle.yml @@ -0,0 +1,197 @@ +name: Bundle + +# אורז את **החבילה המלאה**: ה-exe שכבר פורסם, ואחריו מראה שלמה של כל +# התוספים משורשרת לסופו. מי שמוריד אותה מריץ פעם אחת, והכול נפרס +# לתיקייה שלידה — בלי אינטרנט, אף פעם. +# +# ⚠️ ה-workflow הזה **אינו מקדם גרסה ואינו מתייג `v`**. הגרסה היא +# מספר שלם אחד שמתקדם רק בדחיפה ל-main (ci.yml), ו-release בשם כזה כאן +# היה מודיע לכל המשתמשים על גרסה חדשה כשהקוד זהה — ראו +# `store_release_client.js`, שבוחר את ה-`v` היציב הגבוה כ"יש עדכון". +# +# לכן התג הוא `bundle` **מתגלגל**: תמיד אותו אחד, מתעדכן במקום. גם +# משום ש-`fetchLatestStable` קורא דף אחד של 30 releases — פרסום חדש בכל +# ריצה היה דוחף אחרי כמה חודשים את כל ה-`v` מחוץ לדף, והתראת העדכון +# הייתה מפסיקה לעבוד בשקט. + +on: + # יומי ולא שבועי: הזיהוי זול (הזריעה + מטא-דאטה), והאריזה קורית רק + # כשמשהו באמת זז. עדכון תוסף לא ימתין שבוע. + schedule: + - cron: "0 3 * * *" + # release חדש של החנות עצמה — ה-exe שבתוך החבילה התיישן. אירוע, לא סקר. + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +# אריזה אחת בכל רגע: שתי ריצות במקביל היו נלחמות על אותו תג. +concurrency: + group: bundle + cancel-in-progress: false + +jobs: + bundle: + name: pack the full bundle + # ⚠️ **windows דווקא**: `mirror_store.js` בונה נתיבים מוחלטים עם `\`, + # ועל לינוקס זה מייצר קבצים ששם הקובץ שלהם מכיל backslash. + runs-on: windows-latest + # דחייה של ריצת CI שנכשלה — אין טעם לארוז exe שלא פורסם. + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.conclusion == 'success' + env: + GH_TOKEN: ${{ github.token }} + BUNDLE_TAG: bundle + BUNDLE_ASSET: Otzaria-Plugin-Store-Full.exe + SLIM_ASSET: Otzaria-Plugin-Store.exe + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + + # ── ה-exe שייכנס לחבילה ──────────────────────────────────────────── + # נמשך מה-release ולא נבנה כאן: מה שמופץ בחבילה חייב להיות בדיוק + # הבינארי שנבדק ופורסם, ולא בנייה שנייה של אותו קומיט. + - name: Fetch the published exe + shell: bash + run: | + set -euo pipefail + # הגבוה מבין `v`, כמו `storeVersionOf` בתוכנה — סדר הפרסום + # אינו סדר הגרסאות, ו"הראשון ברשימה" היה תלוי-מזל. + tag="$(gh release list --limit 100 --json tagName \ + --jq '[.[].tagName | select(test("^v[0-9]+$"))] + | sort_by(.[1:] | tonumber) | last')" + if [ -z "$tag" ] || [ "$tag" = "null" ]; then + echo "::error::אין אף release בצורה v — אין exe לארוז" + exit 1 + fi + echo "ה-exe נלקח מ-$tag" + echo "STORE_TAG=$tag" >> "$GITHUB_ENV" + gh release download "$tag" -p "$SLIM_ASSET" -D slim + + # ── זריעה מהחבילה הקודמת ─────────────────────────────────────────── + # בלעדיה כל ריצה הייתה מושכת ‎96MB מ-otzaria.org. איתה `#plan` + # מוריד רק את הדלתא — ובדרך אגב זה מריץ ב-CI בדיוק את המסלול + # האינקרמנטלי שכונן של משתמש עובר בעדכון. + - name: Seed from the previous bundle + shell: bash + run: | + set -euo pipefail + if gh release download "$BUNDLE_TAG" -p "$BUNDLE_ASSET" -D previous + then + node native/tools/unpack_overlay.mjs "previous/$BUNDLE_ASSET" Data + cp Data/catalog.json catalog.before.json + node native/tools/mirror_changed.mjs --inventory Data/plugins \ + > inventory.before.txt + # ה-exe שבתוך החבילה הקודמת — להשוואה מול זה שהורד עכשיו. + node native/tools/unpack_overlay.mjs --slim \ + "previous/$BUNDLE_ASSET" previous-slim.exe + else + echo "אין חבילה קודמת — הפרסום הראשון בונה מראה מאפס" + fi + + # ── הסנכרון ──────────────────────────────────────────────────────── + # מריץ את `PluginMirrorSync` **עצמו**. כללי הבחירה — איזה בילד לאיזו + # גרסת אוצריא, מה נחשב "לא השתנה" — נשארים במקום אחד, ואין כאן + # מימוש שני שלהם שייפרד מהקוד ביום שאחד מהם ישתנה. + - name: Build the mirror + shell: bash + run: node native/tools/pack_mirror.mjs Data + + - name: Is there anything to publish? + id: changed + shell: bash + run: | + # ⚠️ שני תנאים, ולא רק המראה: כשמתפרסם exe חדש המראה אינה + # משתנה, והשוואת המראה לבדה הייתה משאירה את החבילה עם בינארי + # ישן לנצח — כלומר הטריגר `workflow_run` לא היה עושה כלום. + if [ ! -f previous-slim.exe ] || + ! cmp -s "slim/$SLIM_ASSET" previous-slim.exe; then + echo "ה-exe של החנות השתנה — מפרסמים" + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + set +e + node native/tools/mirror_changed.mjs \ + catalog.before.json Data/catalog.json \ + inventory.before.txt Data/plugins + case $? in + 0) echo "publish=true" >> "$GITHUB_OUTPUT" ;; + 1) echo "publish=false" >> "$GITHUB_OUTPUT" ;; + *) exit 1 ;; + esac + + # ── האריזה ───────────────────────────────────────────────────────── + - name: Pack the bundle + if: steps.changed.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + node native/tools/pack_overlay.mjs \ + "slim/$SLIM_ASSET" Data "$BUNDLE_ASSET" + + # המשתמש של החבילה **מנותק**: חבילה פגומה אינה ניתנת לתיקון אצלו, + # והוא גם לא ידע מה נשבר. הפריסה חוזרת כאן ומושווית לפני שמפרסמים. + - name: Verify the bundle round-trips + if: steps.changed.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + node native/tools/unpack_overlay.mjs "$BUNDLE_ASSET" verify + # `diff -r` ולא השוואת שמות: שם כמו `image.webp` חוזר 35 פעם + # בתיקיות שונות, והשוואה לפי שם וגודל בלבד לא הייתה מבחינה + # ביניהם. כאן נבדק **התוכן**, קובץ מול קובץ. + diff -r Data verify + size=$(node -e " + console.log((require('fs').statSync(process.argv[1]).size / + 1048576).toFixed(1)); + " "$BUNDLE_ASSET") + echo "$BUNDLE_ASSET — ${size}MB" + + # ── הפרסום ───────────────────────────────────────────────────────── + - name: Publish to the rolling release + if: steps.changed.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + plugins="$(node -e " + const c = require('./Data/catalog.json'); + const withFile = c.plugins.filter( + (p) => Object.keys(p.localFiles ?? {}).length > 0).length; + console.log(withFile + '|' + c.targetAppVersions.join(', ')); + ")" + count="${plugins%%|*}" + targets="${plugins##*|}" + # ⚠️ בלי הזחה: הטקסט נמסר כמו שהוא, ושורה מוזחת ב-markdown של + # GitHub נעשית בלוק קוד. + notes="$(printf '%s\n' \ + "חבילה מלאה: $count תוספים ארוזים בתוך קובץ ההרצה." \ + "" \ + "נבנתה עבור אוצריא $targets, על גבי $STORE_TAG." \ + "עודכנה: $(date -u '+%Y-%m-%d')" \ + "" \ + "הרצה אחת פורסת את הכול לתיקייה \`Data\` שלצד קובץ ההרצה, ומשם התוכנה עובדת בלי אינטרנט.")" + + # ⚠️ `--clobber` ותג קבוע: החבילה **מתגלגלת**. release חדש בכל + # ריצה היה צובר עשרות פריטים ודוחף את ה-`v` מחוץ לדף שבדיקת + # העדכון קוראת. + if gh release view "$BUNDLE_TAG" >/dev/null 2>&1; then + gh release edit "$BUNDLE_TAG" --notes "$notes" + gh release upload "$BUNDLE_TAG" "$BUNDLE_ASSET" --clobber + else + gh release create "$BUNDLE_TAG" "$BUNDLE_ASSET" \ + --title "חבילה מלאה" --notes "$notes" + fi + + - name: Nothing changed + if: steps.changed.outputs.publish != 'true' + shell: bash + run: echo "המראה זהה לזו שכבר פורסמה — לא פורסמה חבילה חדשה" diff --git a/README.md b/README.md index 36fe28f..689aa13 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Data\ ← נוצרת לידו בהרצה הראשונה ב-`Data\`, והתקנה אינה נוגעת ברשת. זה מה שמאפשר להעתיק את התיקייה לכונן נייד ולהתקין ממנה במחשב מנותק. +ולמי שאין לו רשת גם בפעם הראשונה מתפרסמת [חבילה מלאה](#החבילה-המלאה) — +אותו exe עם כל התוספים כבר בתוכו. + ## מה מיוחד כאן **‎0.5MB, קובץ אחד.** אין מתקין, אין DLL לצד ה-exe, ואין תיקיית חילוץ. @@ -35,6 +38,43 @@ Data\ ← נוצרת לידו בהרצה הראשונה מנוע רינדור שלם (`flutter_windows.dll` לבדו ‎21MB לא-דחוס), ופרסה ‎29MB לתיקיית `app-files\` בהרצה הראשונה כדי להריץ משם קובץ הרצה שני. +## החבילה המלאה + +מי שמתקין במחשב מנותק צריך שהתוספים כבר יהיו אצלו. לכן מתפרסמת, לצד +ה-exe הרזה, **חבילה מלאה**: אותו קובץ הרצה בדיוק, עם מראה שלמה של כל +התוספים משורשרת לסופו. + +``` +Otzaria-Plugin-Store-Full.exe ~96MB, הרצה אחת + ↓ +חנות התוספים.exe 0.52MB — עותק של עצמו בלי המטען +Data\ כל התוספים, מוכנים +``` + +הרצה ראשונה פורסת את `Data\`, כותבת עותק רזה של עצמה, מפעילה אותו +ויוצאת. הרזה שואל אם למחוק את קובץ ההתקנה — **ולא מוחק מעצמו**: אותה +חבילה עשויה להיות מיועדת גם למחשב שני, ומחיקה שקטה של ‎96MB שהורדו הייתה +מאלצת הורדה מחדש. + +**החילוץ מחליף את `plugins\` ואת `catalog.json` במלואם**, ולא ממזג. +המראה היא נגזרת של (קטלוג, גרסאות היעד) ולא נתוני משתמש — זה בדיוק מה +ש-`pruneUnusedFiles` כבר עושה, רק בבת אחת. `state.json` ו-`logs\`, שהם +מקומיים למכונה, אינם נגעים. + +⚠️ **החותמת נכתבת אחרונה**, אחרי שהקובץ האחרון נחת. חילוץ שנקטע משאיר +תיקייה בלי חותמת, וההרצה הבאה עושה אותו שוב — המטען עדיין בתוך ה-exe. +זו כל האטומיות שנדרשת, ולכן אין תיקייה זמנית ואין כפל מקום. + +### למה שרשור ולא resource + +המטען אינו נדחס: `.otzplugin` הוא ZIP והתמונות webp/png, ו-LZMS עליהם +מחזיר אחוזים בודדים תמורת פרישה של ‎96MB לזיכרון. שרשור לסוף ה-PE משאיר +את הבינארי שפורסם **בייט-לבייט** — כך שהעותק הרזה שנכתב בחילוץ הוא בדיוק +ה-exe שנבדק — ומאפשר חילוץ בזרימה בלי הקצאה גדולה אחת. RCDATA היה מחייב +בנייה מחדש של ה-exe סביב המטען, כלומר בינארי אחר מזה שפורסם. + +הפורמט מוגדר ב-`native/tools/pack_overlay.mjs` ונקרא ב-`native/src/overlay.cpp`. + ## מבנה ``` @@ -42,7 +82,7 @@ native/ src/ ה-host ב-C++: חלון, WebView2, וגשר לקבצים/רשת/רג'יסטרי web/ הממשק וכל הלוגיקה — HTML/CSS/JS, בלי bundler ובלי npm test/ בדיקות `node --test` - tools/ כלי בנייה ופיתוח + tools/ כלי בנייה ופיתוח, וכלי האריזה של החבילה המלאה third_party/ ה-SDK של WebView2 (מובא מ-nuget, אינו בגיט) build.ps1 הבנייה כולה VERSION מספר הגרסה — מקום אחד @@ -108,6 +148,14 @@ cd native && node --test node native/tools/seed_catalog.mjs native/build/Data ``` +```bash +# בונה מראה מלאה בהרצת הסנכרון האמיתי, ואורז אותה לחבילה — בדיוק מה +# שהג'וב עושה, כדי שאפשר יהיה לשחזר תקלה בלי GitHub. +node native/tools/pack_mirror.mjs Data +node native/tools/pack_overlay.mjs "native/build/חנות התוספים.exe" Data Full.exe +node native/tools/unpack_overlay.mjs Full.exe verify # ואז diff -r Data verify +``` + ```bash # מחלץ מחדש את האייקונים מגופני Fluent UI אל native/web/js/ui/icons.js. # להריץ רק כשמוסיפים אייקון (ראו ICONS שבסקריפט). @@ -140,6 +188,37 @@ node native/tools/extract_icons.mjs התוכנה בודקת פעם אחת בהרצה אם יצא release חדש ומציגה על כך שורה. **אין עדכון עצמי**: הסיפור מסתיים בכתובת שנפתחת בדפדפן. +### החבילה המלאה — `.github/workflows/bundle.yml` + +רץ יומית, וגם כשריצת CI הצליחה ב-`main` (‏exe חדש), וגם ביד. **אינו +מקדם גרסה ואינו מתייג `v`**: התג הוא `bundle` **מתגלגל**, תמיד אותו +אחד, מתעדכן במקום. + +שתי סיבות, שתיהן ב-`store_release_client.js`: + +* `isStoreReleaseTag` פוסל כל תג שאינו `^v?\d+$`, ולכן `bundle` נעלם + מבדיקת העדכון — אחרת כל אריזה הייתה מודיעה למשתמשים על "גרסה חדשה" + כשהקוד זהה. +* `fetchLatestStable` קורא **דף אחד של 30 releases**. פרסום חדש בכל + ריצה היה דוחף אחרי כמה חודשים את כל ה-`v` מחוץ לדף, והתראת העדכון + הייתה מפסיקה לעבוד בשקט. + +הג'וב **זורע את עצמו מהחבילה הקודמת** לפני שהוא מסנכרן, וכך `#plan` +מוריד מ-otzaria.org רק את הדלתא: עדכון של תוסף אחד עולה קובץ אחד ולא +‎96MB. בדרך אגב זה מריץ ב-CI בדיוק את המסלול האינקרמנטלי שכונן של משתמש +עובר בעדכון, ולא "מראה ריקה" שאיש אינו חי בה. + +**מה נחשב שינוי** נקבע ב-`mirror_changed.mjs`, בהשוואת התוצאה ולא +בגיבוב של ה-API. ⚠️ המונים (`downloadCount`, הדירוגים) מנוטרלים +מההשוואה: נמדד שסנכרון שדילג על כל 38 התוספים ולא הוריד דבר עדיין +הפיק קטלוג שונה, וכל ההבדל היה מונה ההורדות של תוסף אחד — כלומר בלי +הנטרול הג'וב היה מפרסם ‎96MB בכל ריצה. + +⚠️ **לא רק המראה נבדקת אלא גם ה-exe עצמו**: כשמתפרסם בינארי חדש המראה +אינה משתנה, והשוואת המראה לבדה הייתה משאירה את החבילה עם exe ישן לנצח. +הג'וב מחלץ את ה-exe מהחבילה הקודמת (`unpack_overlay.mjs --slim`) ומשווה +בייטים. + ## הערות * **תיקיית הנתונים תמיד צמודה לקובץ ההרצה ואינה ניתנת לשינוי** — זו @@ -153,7 +232,10 @@ node native/tools/extract_icons.mjs ששורדים את סגירת החלון ומחזיקים מנעולים על הקבצים. * **שם קובץ ההרצה אינו מכיל "אוצריא"** בכוונה: זיהוי ההתקנה של אוצריא עובד לפי השם, ו-exe בשם כזה שיושב ב-`C:\אוצריא` היה נבחר שם כהתקנה של - אוצריא עצמה. + אוצריא עצמה. ⚠️ הזיהוי מתאים גם ל-`otzaria` הלטיני, ולכן גם + `Otzaria-Plugin-Store-Full.exe` — קובץ ההתקנה של החבילה, שהמשתמש עשוי + לבחור להשאיר — נמצא ב-`OUR_OWN_EXE_NAMES` שב-`otzaria_probe.js`. + ‏`selfPath` אינו מכסה אותו: אחרי הפריסה מי שרץ הוא ה-exe הרזה. * **ההתקנה נמסרת לאוצריא** דרך `otzaria://plugin/install-local` — ולא בפרישת ה-ZIP בעצמנו, כי אוצריא מנהלת רישום פנימי לתוספים המותקנים ופרישה ידנית עוקפת אותו. כשנתיב ההתקנה ידוע ה-URL נמסר לקובץ ההרצה diff --git a/native/build.ps1 b/native/build.ps1 index b09a3e4..0b48c8b 100644 --- a/native/build.ps1 +++ b/native/build.ps1 @@ -95,8 +95,8 @@ if (-not (Test-Path $vcvars)) { throw "vcvars64.bat not found at $vcvars." } # ── 5. קומפילציה ולינק ─────────────────────────────────────────────────────── $sources = @( - 'main.cpp', 'bridge.cpp', 'common.cpp', 'fsapi.cpp', - 'netapi.cpp', 'paths.cpp', 'sysapi.cpp', 'webres.cpp' + 'main.cpp', 'bridge.cpp', 'common.cpp', 'fsapi.cpp', 'netapi.cpp', + 'overlay.cpp', 'paths.cpp', 'sysapi.cpp', 'webres.cpp' ) | ForEach-Object { '"' + (Join-Path $srcDir $_) + '"' } # /MT — CRT סטטי, וזו החלטה מרכזית: בלעדיו ה-exe דורש @@ -118,10 +118,13 @@ if ($Debug) { $linkExtra = '/OPT:REF /OPT:ICF' } +# comctl32 — ה-progress bar של חלון פריסת החבילה (overlay.cpp). הוא +# משאיר את ה-exe בגודלו: ה-DLL כבר טעון בכל תהליך שיש לו חלון. $libs = @( 'version.lib', 'shlwapi.lib', 'dwmapi.lib', 'winhttp.lib', 'cabinet.lib', 'crypt32.lib', 'ole32.lib', 'oleaut32.lib', 'shell32.lib', 'user32.lib', - 'gdi32.lib', 'advapi32.lib', 'kernel32.lib', 'comdlg32.lib', 'uuid.lib' + 'gdi32.lib', 'advapi32.lib', 'kernel32.lib', 'comdlg32.lib', 'uuid.lib', + 'comctl32.lib' ) -join ' ' $exePath = Join-Path $outDir $appFileName diff --git a/native/src/bridge.cpp b/native/src/bridge.cpp index 1258c39..9afd829 100644 --- a/native/src/bridge.cpp +++ b/native/src/bridge.cpp @@ -3,6 +3,7 @@ #include "common.h" #include "fsapi.h" #include "netapi.h" +#include "overlay.h" #include "sysapi.h" namespace otz { @@ -29,6 +30,39 @@ std::wstring Arg(const std::vector& fields, size_t index) { return at < fields.size() ? fields[at] : std::wstring{}; } +// נתיב חבילת ההתקנה הגדולה שממנה נפרסה המראה, כשהיא עדיין יושבת לצד +// ה-exe. ריק = אין מה להציע. +// +// זה מה שמאפשר לממשק להציע למשתמש למחוק ~‎96MB שאין בהם עוד שום צורך: +// המראה כבר על הדיסק, וקובץ ההרצה הרזה שרץ עכשיו הוא כל מה שדרוש. +// המחיקה עצמה נעשית ב-JS דרך `fs.remove` — אין כאן פקודה חדשה, כי זו +// מחיקת קובץ יחיד ככל מחיקת קובץ אחרת. +std::wstring InstallerPath(const AppPaths& paths) { + StampFile stamp; + if (!ReadStampFile(JoinPath(paths.data_dir, kStampFileName), stamp)) return {}; + if (stamp.installer.empty()) return {}; + + // הנתיב נרשם מלא, אבל **מורכב מחדש** מול התיקייה של ה-exe הנוכחי: + // התיקייה כולה (ה-exe, החבילה ו-`Data\`) נועדה לנסוע לכונן נייד, ואז + // הנתיב המוחלט שנרשם במחשב האחר כבר אינו קיים — בעוד שהקובץ עצמו + // יושב בדיוק במקום שהובטח, ליד התוכנה. + const size_t slash = stamp.installer.find_last_of(L"\\/"); + const std::wstring name = slash == std::wstring::npos + ? stamp.installer + : stamp.installer.substr(slash + 1); + if (name.empty()) return {}; + const std::wstring path = JoinPath(ExecutableDir(), name); + + // ⚠️ אנחנו עצמנו. קורה כשמריצים שוב את החבילה אחרי שהיא כבר נפרסה — + // ואין להציע למשתמש למחוק את הקובץ שהוא מריץ ברגע זה. + if (lstrcmpiW(path.c_str(), ExecutablePath().c_str()) == 0) return {}; + + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) return {}; + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) return {}; + return path; +} + long long ArgNumber(const std::vector& fields, size_t index, long long fallback) { const std::wstring text = Arg(fields, index); @@ -291,6 +325,10 @@ void Bridge::HandleMessage(const std::wstring& message) { {"stateDir", JsonString(paths_.state_dir)}, {"stateFile", JsonString(paths_.StateFilePath())}, {"exePath", JsonString(ExecutablePath())}, + // ריק = אין חבילת התקנה לצד ה-exe. כשיש + // כאן נתיב, הממשק מציע למחוק אותה — ראו + // [InstallerPath]. + {"installerPath", JsonString(InstallerPath(paths_))}, {"readOnly", JsonBool(paths_.read_only)}, {"darkMode", JsonBool(sysapi::IsSystemDarkMode())}, // ריק = הכול תקין. כשיש כאן טקסט, הממשק מציג diff --git a/native/src/main.cpp b/native/src/main.cpp index c14e56c..4cb59de 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -6,6 +6,11 @@ // של WebView2 מקושר סטטית, וקובצי הממשק יושבים כ-resource בתוך ה-exe. // מה שנוצר ליד התוכנה זו **רק** תיקיית `Data\`. // +// היוצא היחיד מהכלל הוא ה**חבילה** — אותו exe בדיוק עם מראה של ~‎96MB +// משורשרת לסופו, בשביל מחשב מנותק. גם היא אינה מתקינה דבר: היא פורסת +// את `Data\`, כותבת לצדה את אותו exe בלי המטען, מפעילה אותו ויוצאת. +// ראו [RunOverlaySetup] כאן ו-overlay.h. +// // ── החלון חסר-המסגרת ───────────────────────────────────────────────────────── // הסגנון הוא `WS_POPUP | WS_THICKFRAME`: בלי `WS_CAPTION`, ולכן DWM אינו // מצייר שורת כותרת בכלל, ועם מסגרת עבה שנותנת את הצל ואת פינות ווינדוס 11. @@ -43,6 +48,7 @@ #include "WebView2.h" #include "bridge.h" #include "netapi.h" +#include "overlay.h" #include "paths.h" #include "resource.h" #include "sysapi.h" @@ -244,6 +250,136 @@ bool ReadDataFile(const std::wstring& relative, std::vector& out) { return true; } +// ── פריסת החבילה ───────────────────────────────────────────────────────────── +// רץ **לפני** שה-WebView2 עולה, וברוב ההרצות אינו רץ בכלל: ה-exe הרזה +// אינו נושא מטען, ו-[HasOverlay] חוזר `false` אחרי קריאה של 96 בייט. + +// שם קובץ ההרצה הרזה שנכתב לצד החבילה. חייב להתאים ל-`$appFileName` +// שב-native/build.ps1 ול-`OriginalFilename` שב-app.rc — זה אותו קובץ. +constexpr wchar_t kSlimExeName[] = L"חנות התוספים.exe"; + +// מריץ את הפריסה אם צריך. `true` = התוכנה סיימה את תפקידה בהרצה הזאת +// ועליה לצאת עם [exit_code]; `false` = להמשיך ולעלות כרגיל. +bool RunOverlaySetup(HINSTANCE instance, const OverlayInfo& overlay, + int& exit_code) { + const std::wstring exe_path = ExecutablePath(); + const std::wstring stamp_path = JoinPath(g_paths.data_dir, kStampFileName); + + // כבר נפרסה. קורה כשמריצים את החבילה עצמה פעם שנייה — ואז היא פשוט + // חנות רגילה שבמקרה שוקלת ‎96MB. + StampFile existing; + if (ReadStampFile(stamp_path, existing) && existing.stamp == overlay.stamp) { + LogInfo(L"החבילה כבר פרוסה — ממשיכים כרגיל"); + return false; + } + + // ⚠️ הפריסה **חייבת** את התיקייה שליד ה-exe: `Data\` אינה ניתנת + // להזזה (ראו paths.h), ולכן אין כאן מסלול חלופי לתיקיית המשתמש. + // כשל שקט כאן היה משאיר את המשתמש עם חנות ריקה בלי לדעת למה. + if (!g_paths.error.empty() || g_paths.read_only) { + LogError(L"אי אפשר לפרוס את החבילה — התיקייה אינה כותבת: " + + g_paths.data_dir); + const std::wstring message = + L"החנות נושאת איתה את כל קובצי התוספים, וכדי לפרוס אותם היא צריכה " + L"לכתוב לתיקייה שלידה:\n" + + g_paths.data_dir + + L"\n\nהמיקום הזה אינו מאפשר כתיבה. יש להעתיק את הקובץ למקום שניתן " + L"לכתוב בו — למשל לתיקיית ההורדות או לכונן נייד — ולהריץ אותו משם."; + MessageBoxW(nullptr, message.c_str(), kWindowTitle, MB_ICONERROR | MB_OK); + exit_code = 1; + return true; + } + + LogInfo(L"פריסת החבילה מתחילה"); + ProgressWindow progress; + progress.Create(instance, kWindowTitle, + L"מכינה את חנות התוספים להרצה ראשונה…"); + + // ── הניקוי ──────────────────────────────────────────────────────────── + // ⚠️ **סדר הפעולות כאן הוא ההגנה כולה.** החותמת נמחקת ראשונה: פריסה + // שתיקטע אחרי שהמראה כבר נמחקה אבל לפני שהחדשה נחתה חייבת להיראות + // בהרצה הבאה כ"לא נפרס", ולא כ"נפרסה החבילה הקודמת". + DeleteFileW(stamp_path.c_str()); + + // המראה היא **נגזרת** של החבילה ולא נתוני משתמש, וההחלפה סיטונית: + // מה ששורד מפריסה קודמת הוא תוספים שכבר אינם בקטלוג הזה, שהיו נשארים + // בתיקייה לנצח. + // + // ⚠️ נמחקים **רק** שני אלה. `state.json` ו-`logs\` יושבים באותה + // תיקייה בדיוק (ראו paths.h) והם מקומיים למכונה — מה שזוהה על התקנת + // אוצריא שבמחשב הזה, ומה שקרה בהרצות הקודמות. מחיקה שלהם הייתה גורמת + // לזיהוי מחדש בכל עדכון חבילה, ומוחקת בדיוק את היומן שמסביר תקלה. + RemoveTree(g_paths.PluginsDir()); + DeleteFileW(g_paths.CatalogPath().c_str()); + + // ── הפריסה ──────────────────────────────────────────────────────────── + std::wstring error; + const bool extracted = ExtractOverlay( + exe_path, overlay, g_paths.data_dir, + [&progress](uint64_t done, uint64_t total) { progress.Update(done, total); }, + error); + if (!extracted) { + progress.Close(); + LogError(L"פריסת החבילה נכשלה: " + error); + MessageBoxW(nullptr, + (L"פריסת קובצי החנות נכשלה.\n\n" + error).c_str(), kWindowTitle, + MB_ICONERROR | MB_OK); + exit_code = 1; + return true; + } + + // ── החותמת, אחרונה ──────────────────────────────────────────────────── + // רק עכשיו, אחרי שהקובץ האחרון נחת. השורה השנייה היא הנתיב של החבילה + // עצמה — זה מה שמאפשר ל-exe הרזה להציע למשתמש למחוק אותה (ראו + // `installerPath` ב-bridge.cpp). + StampFile stamp; + stamp.stamp = overlay.stamp; + stamp.installer = exe_path; + if (!WriteStampFile(stamp_path, stamp, error)) { + progress.Close(); + LogError(L"כתיבת החותמת נכשלה: " + error); + MessageBoxW(nullptr, error.c_str(), kWindowTitle, MB_ICONERROR | MB_OK); + exit_code = 1; + return true; + } + + // ── העותק הרזה ──────────────────────────────────────────────────────── + const std::wstring slim = JoinPath(ExecutableDir(), kSlimExeName); + + // ⚠️ החבילה **היא** התהליך שרץ עכשיו, ובווינדוס אי אפשר לכתוב לקובץ + // ההרצה של תהליך רץ. זה קורה כשמישהו שינה לחבילה את שמה לשם של ה-exe + // הרזה: אין למי למסור את השרביט, והתשובה הנכונה היא פשוט להמשיך + // ולעלות — התוכנה כאן עובדת, היא רק גדולה. + if (lstrcmpiW(slim.c_str(), exe_path.c_str()) == 0) { + progress.Close(); + LogInfo(L"החבילה כבר נושאת את שם ה-exe הרזה — ממשיכים בלי עותק"); + return false; + } + + if (!WriteSlimCopy(exe_path, overlay.data_offset, slim, error)) { + progress.Close(); + LogError(L"כתיבת העותק הרזה נכשלה: " + error); + MessageBoxW(nullptr, + (L"כתיבת קובץ ההרצה של החנות נכשלה.\n\n" + error).c_str(), + kWindowTitle, MB_ICONERROR | MB_OK); + exit_code = 1; + return true; + } + + // ── מסירת השרביט ────────────────────────────────────────────────────── + progress.Close(); + if (!LaunchAndForget(slim, error)) { + // ההרצה נכשלה אבל הכול כבר על הדיסק — עדיף להמשיך ולעלות מכאן מאשר + // להציג שגיאה על תוכנה שבפועל מוכנה לגמרי. + LogError(L"הרצת העותק הרזה נכשלה, ממשיכים בתהליך הזה: " + error); + return false; + } + + LogInfo(L"הפריסה הסתיימה — השרביט נמסר ל-" + slim); + exit_code = 0; + return true; +} + // ── פעולות שמחייבות את ה-thread של הממשק ───────────────────────────────────── void HandleWindowCommand(const std::wstring& command, @@ -657,6 +793,16 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) { LogInfo(L"הכונן לקריאה בלבד — היומן והמצב נכתבים ל-" + g_paths.state_dir); } + // ── החבילה, לפני כל השאר ────────────────────────────────────────────────── + // חייב לרוץ לפני שה-WebView2 עולה: הממשק קורא את `Data\` מיד בעלייתו, + // ואם היא עומדת להיפרס אין טעם שהוא יראה אותה קודם. ברוב ההרצות זו + // קריאה של 96 בייט שחוזרת ריקה. + OverlayInfo overlay; + if (HasOverlay(ExecutablePath(), overlay)) { + int exit_code = 0; + if (RunOverlaySetup(instance, overlay, exit_code)) return exit_code; + } + g_dark_mode = sysapi::IsSystemDarkMode(); // חבילת הממשק. כשל כאן הוא כשל **בנייה** ולא מצב ריצה. diff --git a/native/src/overlay.cpp b/native/src/overlay.cpp new file mode 100644 index 0000000..4e69b52 --- /dev/null +++ b/native/src/overlay.cpp @@ -0,0 +1,695 @@ +#include "overlay.h" + +#include + +#include +#include +#include + +#include "paths.h" +#include "resource.h" + +namespace otz { +namespace { + +// גודל הגוש שבו זורם המטען. ‎1MB הוא המקום שבו סיבובי ה-I/O כבר זניחים +// מול הזמן שהכונן עצמו לוקח, וגדול ממנו רק תופס זיכרון. +// +// ⚠️ **זה כל מה שמוקצה**, בלי קשר לגודל המטען: ‎96MB לא נטענים לזיכרון +// בשום שלב, וזו הסיבה שהפורמט אינו דחוס (ראו overlay.h). +constexpr size_t kCopyChunk = 1u << 20; + +// תקרה שפויה לאינדקס. הוא מטא-דאטה בלבד — נתיב וגודל לכל קובץ — ומראה +// של אלפי קבצים מייצרת פחות מ-‎1MB. ערך גדול מכאן פירושו footer פגום, +// ואסור שהוא יגרור הקצאה של גיגה-בייט. +constexpr uint64_t kMaxIndexBytes = 64ull * 1024 * 1024; + +// ── עזרי קבצים ─────────────────────────────────────────────────────────────── + +// סוגר את ה-handle בכל יציאה מהפונקציה. +// +// יש בקובץ הזה עשרות מסלולי כשל שכל אחד מהם מחזיר הודעה אחרת, ובקוד עם +// `CloseHandle` ידני בכל אחד מהם דליפה היא רק שאלה של זמן. +class ScopedHandle { + public: + explicit ScopedHandle(HANDLE handle) : handle_(handle) {} + ~ScopedHandle() { + if (valid()) CloseHandle(handle_); + } + ScopedHandle(const ScopedHandle&) = delete; + ScopedHandle& operator=(const ScopedHandle&) = delete; + + HANDLE get() const { return handle_; } + bool valid() const { + return handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE; + } + + private: + HANDLE handle_; +}; + +// קריאה מלאה מ-offset מוחלט. `ReadFile` רשאי להחזיר פחות ממה שביקשנו +// גם בלי שגיאה, ולכן הלולאה — קריאה חלקית שנחשבת מלאה היא בדיוק סוג +// הבאג שמתגלה רק על כונן איטי. +bool ReadAt(HANDLE file, uint64_t offset, void* buffer, size_t size) { + LARGE_INTEGER at{}; + at.QuadPart = static_cast(offset); + if (!SetFilePointerEx(file, at, nullptr, FILE_BEGIN)) return false; + + auto* out = static_cast(buffer); + size_t done = 0; + while (done < size) { + const size_t left = size - done; + const DWORD want = static_cast(left < kCopyChunk ? left : kCopyChunk); + DWORD read = 0; + if (!ReadFile(file, out + done, want, &read, nullptr) || read == 0) { + return false; + } + done += read; + } + return true; +} + +// כתיבה מלאה. אותו שיקול כמו ב-[ReadAt] — כתיבה חלקית אפשרית. +bool WriteAll(HANDLE file, const void* buffer, size_t size) { + const auto* bytes = static_cast(buffer); + size_t done = 0; + while (done < size) { + DWORD written = 0; + if (!WriteFile(file, bytes + done, static_cast(size - done), &written, + nullptr) || + written == 0) { + return false; + } + done += written; + } + return true; +} + +// שדות ה-footer וה-אינדקס הם little-endian, וזה גם הסדר של המעבד כאן. +// הקריאה עוברת ב-`memcpy` ולא ב-cast כדי לא להסתמך על יישור. +uint32_t LoadU32(const uint8_t* at) { + uint32_t value = 0; + memcpy(&value, at, sizeof(value)); + return value; +} + +uint64_t LoadU64(const uint8_t* at) { + uint64_t value = 0; + memcpy(&value, at, sizeof(value)); + return value; +} + +// מוחק קובץ יחיד. `read-only` מנוקה ומנסים שוב: קבצים שהועתקו מ-CD או +// מכונן שהוגן מפני כתיבה נושאים את הדגל הזה, ו-`DeleteFileW` נכשל עליו. +bool DeleteOne(const std::wstring& path) { + if (DeleteFileW(path.c_str())) return true; + const DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return true; + if (error != ERROR_ACCESS_DENIED) return false; + SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_NORMAL); + return DeleteFileW(path.c_str()) != 0; +} + +bool RemoveEmptyDir(const std::wstring& path) { + if (RemoveDirectoryW(path.c_str())) return true; + const DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return true; + if (error != ERROR_ACCESS_DENIED) return false; + SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_DIRECTORY); + return RemoveDirectoryW(path.c_str()) != 0; +} + +// ממיר נתיב מהאינדקס (POSIX, יחסי ל-`Data\`) לנתיב ווינדוס יחסי. +// +// ⚠️ החבילה נבנית אצלנו, אבל היא מגיעה למשתמש כקובץ שהורד מהרשת — ולכן +// האינדקס שבה הוא **קלט לא-מהימן** לכל דבר. בלי הבדיקות האלה `..\..\` +// או `C:\` בשדה נתיב היו הופכים את הפריסה לכתיבה חופשית לכל מקום +// בכונן. אותו שיקול בדיוק כמו ב-`ReadDataFile` שב-main.cpp. +bool SafeRelativePath(const std::string& utf8, std::wstring& out) { + const std::wstring wide = Utf16(utf8); + if (wide.empty()) return false; + // כונן (`C:`) או זרם חלופי (`file:stream`) — שניהם יוצאים מהתיקייה. + if (wide.find(L':') != std::wstring::npos) return false; + + out.clear(); + size_t start = 0; + while (true) { + const size_t slash = wide.find_first_of(L"/\\", start); + const size_t end = slash == std::wstring::npos ? wide.size() : slash; + const std::wstring segment = wide.substr(start, end - start); + // מקטע ריק תופס גם נתיב מוחלט (`\foo`) וגם `//`; `.` ו-`..` הם + // היציאה מהתיקייה עצמה. + if (segment.empty() || segment == L"." || segment == L"..") return false; + if (!out.empty()) out += L'\\'; + out += segment; + if (slash == std::wstring::npos) break; + start = slash + 1; + } + return !out.empty(); +} + +std::wstring ParentOf(const std::wstring& path) { + const size_t slash = path.find_last_of(L"\\/"); + return slash == std::wstring::npos ? std::wstring{} : path.substr(0, slash); +} + +// ── חלון ההתקדמות ──────────────────────────────────────────────────────────── + +constexpr wchar_t kProgressClass[] = L"OtzariaPluginStoreSetupWindow"; + +// המחוון עובד ב"אלפיות" ולא באחוזים: הוא מצויר ברוחב של מאות פיקסלים, +// ורזולוציה של 1% הייתה נותנת קפיצות גלויות. +constexpr int kProgressRange = 1000; + +// המידות בפיקסלים לוגיים (‎96 DPI); [ProgressWindow::Create] מכפיל אותן +// ב-DPI בפועל. +constexpr int kBoxWidth = 460; +constexpr int kBoxHeight = 152; +constexpr int kBoxMargin = 26; + +// ה-DPI של המסך הראשי. החלון הזה קצר-חיים ונפתח במרכז המסך הראשי, ולכן +// אין כאן מעקב אחרי `WM_DPICHANGED` — הוא ייסגר הרבה לפני שמישהו יגרור +// אותו למסך שני. +int SystemDpi() { + HDC screen = GetDC(nullptr); + if (screen == nullptr) return 96; + const int dpi = GetDeviceCaps(screen, LOGPIXELSX); + ReleaseDC(nullptr, screen); + return dpi > 0 ? dpi : 96; +} + +LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, + LPARAM lparam) { + switch (message) { + case WM_CTLCOLORSTATIC: + // בלי זה הטקסטים מצוירים על מלבן אפור-כפתור, והחלון נראה שבור. + SetBkMode(reinterpret_cast(wparam), TRANSPARENT); + return reinterpret_cast(GetSysColorBrush(COLOR_WINDOW)); + + case WM_CLOSE: + // אין ביטול — ראו overlay.h. בליעה שקטה עדיפה על חלון שנעלם + // ומשאיר פריסה חצי-גמורה שרצה ברקע בלי שום חיווי. + return 0; + + default: + break; + } + return DefWindowProcW(window, message, wparam, lparam); +} + +} // namespace + +// ── זיהוי ──────────────────────────────────────────────────────────────────── + +bool HasOverlay(const std::wstring& exe_path, OverlayInfo& out) { + // `FILE_SHARE_READ` בלבד ומספיק: הקובץ הזה הוא קובץ ההרצה של התהליך + // הרץ, וווינדוס ממילא מרשה לקרוא ממנו ולא לכתוב אליו. + ScopedHandle file(CreateFileW(exe_path.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr)); + if (!file.valid()) return false; + + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file.get(), &size)) return false; + const auto total = static_cast(size.QuadPart); + if (total <= kOverlayFooterSize) return false; + + uint8_t footer[kOverlayFooterSize]{}; + if (!ReadAt(file.get(), total - kOverlayFooterSize, footer, sizeof(footer))) { + return false; + } + // ה-exe הרזה מגיע לכאן בכל הרצה. אין חבילה = אין שגיאה, ואין שורת + // יומן — זה המצב הרגיל. + if (memcmp(footer, kOverlayMagic, sizeof(kOverlayMagic)) != 0) return false; + + OverlayInfo info; + info.index_offset = LoadU64(footer + 8); + info.index_size = LoadU64(footer + 16); + info.data_offset = LoadU64(footer + 24); + + // כל מה שאפשר לאמת מול הגודל בפועל, מאומת כאן — כדי שהקוד שאחרי + // ההצלחה יוכל לסמוך על המספרים בלי לבדוק אותם שוב. + if (info.data_offset == 0 || info.data_offset > info.index_offset) return false; + if (info.index_offset > total || info.index_size > total) return false; + if (info.index_offset + info.index_size != total - kOverlayFooterSize) { + return false; + } + + // החותמת חייבת להיות hex נקי: היא נכתבת כשורה בקובץ טקסט ומושווית + // כמחרוזת, ולא נכניס לשם בייטים שרירותיים מקובץ פגום. + info.stamp.assign(reinterpret_cast(footer + 32), kOverlayStampChars); + for (const char c : info.stamp) { + const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); + if (!hex) return false; + } + + info.payload_size = info.index_offset - info.data_offset; + out = info; + return true; +} + +// ── פריסה ──────────────────────────────────────────────────────────────────── + +bool ExtractOverlay(const std::wstring& exe_path, const OverlayInfo& info, + const std::wstring& data_dir, const ProgressFn& on_progress, + std::wstring& error) { + if (info.index_size > kMaxIndexBytes) { + error = L"האינדקס של החבילה גדול מהסביר — הקובץ כנראה פגום."; + return false; + } + + // `FILE_FLAG_SEQUENTIAL_SCAN` — הקריאה כאן היא מעבר אחד קדימה על + // ‎96MB, וזה בדיוק מה שהדגל אומר למטמון של ווינדוס. + ScopedHandle source(CreateFileW(exe_path.c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!source.valid()) { + error = L"פתיחת קובץ ההתקנה לקריאה נכשלה: " + MessageForError(GetLastError()); + return false; + } + + // האינדקס **כן** נטען לזיכרון — הוא מטא-דאטה, לא המטען. + std::vector index(static_cast(info.index_size)); + if (!index.empty() && + !ReadAt(source.get(), info.index_offset, index.data(), index.size())) { + error = L"קריאת האינדקס של החבילה נכשלה — הקובץ כנראה לא הועתק במלואו."; + return false; + } + + struct Entry { + std::wstring path; + uint64_t size; + }; + std::vector entries; + + // פירוק האינדקס. כל שדה נבדק מול מה שנשאר, כדי שאינדקס קטוע ייפול + // כאן ולא בגישה מחוץ לתחום. + size_t at = 0; + if (index.size() < sizeof(uint32_t)) { + error = L"האינדקס של החבילה קטוע."; + return false; + } + const uint32_t count = LoadU32(index.data()); + at += sizeof(uint32_t); + entries.reserve(count); + + uint64_t declared = 0; + for (uint32_t i = 0; i < count; ++i) { + if (index.size() - at < 12) { + error = L"האינדקס של החבילה קטוע."; + return false; + } + const uint32_t path_bytes = LoadU32(index.data() + at); + const uint64_t file_bytes = LoadU64(index.data() + at + 4); + at += 12; + if (index.size() - at < path_bytes) { + error = L"האינדקס של החבילה קטוע."; + return false; + } + const std::string utf8(reinterpret_cast(index.data() + at), + path_bytes); + at += path_bytes; + + Entry entry; + if (!SafeRelativePath(utf8, entry.path)) { + error = L"החבילה מכילה נתיב שאינו חוקי — יש להוריד אותה מחדש."; + return false; + } + entry.size = file_bytes; + declared += file_bytes; + entries.push_back(std::move(entry)); + } + + // סכום הגדלים חייב לכסות **בדיוק** את אזור התוכן. אינדקס שאינו + // מסתדר עם ה-footer פירושו חבילה פגומה, ולא "נפרוס מה שיש". + if (declared != info.payload_size) { + error = L"החבילה אינה עקבית (סכום הקבצים אינו תואם לגודל המטען)."; + return false; + } + + if (!CreateDirectories(data_dir)) { + error = L"יצירת תיקיית הנתונים נכשלה: " + MessageForError(GetLastError()); + return false; + } + + // מעבר אחד קדימה: התוכן שמור בסדר האינדקס, ולכן אחרי הקפיצה הזאת אין + // עוד ולו קפיצה אחת — רק קריאה רציפה. + LARGE_INTEGER start{}; + start.QuadPart = static_cast(info.data_offset); + if (!SetFilePointerEx(source.get(), start, nullptr, FILE_BEGIN)) { + error = L"מיקום הקריאה בחבילה נכשל."; + return false; + } + + std::vector buffer(kCopyChunk); + uint64_t done = 0; + if (on_progress) on_progress(0, info.payload_size); + + for (const Entry& entry : entries) { + const std::wstring full = JoinPath(data_dir, entry.path); + const std::wstring parent = ParentOf(full); + if (!parent.empty() && !CreateDirectories(parent)) { + error = L"יצירת התיקייה נכשלה:\n" + parent + L"\n" + + MessageForError(GetLastError()); + return false; + } + + ScopedHandle dest(CreateFileW(full.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!dest.valid()) { + error = L"כתיבת הקובץ נכשלה:\n" + full + L"\n" + + MessageForError(GetLastError()); + return false; + } + + uint64_t left = entry.size; + while (left > 0) { + const DWORD want = + static_cast(left < buffer.size() ? left : buffer.size()); + DWORD read = 0; + if (!ReadFile(source.get(), buffer.data(), want, &read, nullptr) || + read == 0) { + error = L"קריאה מהחבילה נכשלה — הקובץ כנראה לא הועתק במלואו."; + return false; + } + if (!WriteAll(dest.get(), buffer.data(), read)) { + error = L"כתיבת הקובץ נכשלה:\n" + full + L"\n" + + MessageForError(GetLastError()); + return false; + } + left -= read; + done += read; + // אחרי כל גוש ולא אחרי כל קובץ: יש כאן קובצי `.otzplugin` של + // עשרות MB, ומחוון שקופא עליהם נראה כמו תוכנה תקועה. + if (on_progress) on_progress(done, info.payload_size); + } + } + + return true; +} + +// ── העותק הרזה ─────────────────────────────────────────────────────────────── + +bool WriteSlimCopy(const std::wstring& exe_path, uint64_t data_offset, + const std::wstring& dest, std::wstring& error) { + ScopedHandle source(CreateFileW(exe_path.c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (!source.valid()) { + error = L"פתיחת קובץ ההתקנה לקריאה נכשלה: " + MessageForError(GetLastError()); + return false; + } + + ScopedHandle target(CreateFileW(dest.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!target.valid()) { + error = L"כתיבת קובץ ההרצה נכשלה:\n" + dest + L"\n" + + MessageForError(GetLastError()); + return false; + } + + // ה-PE שנוצר כאן הוא **בייט-לבייט** ה-exe שנבנה ונבדק: המטען שורשר + // אחריו ולא נגע בו, ולכן חיתוך בגודל המקורי מחזיר בדיוק אותו. + std::vector buffer(kCopyChunk); + uint64_t left = data_offset; + while (left > 0) { + const DWORD want = + static_cast(left < buffer.size() ? left : buffer.size()); + DWORD read = 0; + if (!ReadFile(source.get(), buffer.data(), want, &read, nullptr) || + read == 0) { + error = L"קריאה מקובץ ההתקנה נכשלה."; + return false; + } + if (!WriteAll(target.get(), buffer.data(), read)) { + error = L"כתיבת קובץ ההרצה נכשלה:\n" + dest + L"\n" + + MessageForError(GetLastError()); + return false; + } + left -= read; + } + return true; +} + +bool LaunchAndForget(const std::wstring& exe_path, std::wstring& error) { + // `CreateProcessW` כותב לתוך שורת הפקודה, ולכן היא מאגר ולא קבוע. + std::wstring command = L"\"" + exe_path + L"\""; + + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + + // ה-cwd הוא התיקייה של ה-exe עצמו — שם יושבת `Data\` שהוא עומד לפתוח. + const std::wstring working_dir = ParentOf(exe_path); + const BOOL ok = CreateProcessW( + exe_path.c_str(), command.data(), nullptr, nullptr, FALSE, + DETACHED_PROCESS, nullptr, + working_dir.empty() ? nullptr : working_dir.c_str(), &startup, &process); + if (!ok) { + error = L"הרצת החנות נכשלה: " + MessageForError(GetLastError()); + return false; + } + + // מנותק: לא ממתינים לו, וסגירת ה-handles אינה הורגת אותו. + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return true; +} + +// ── מחיקה רקורסיבית ────────────────────────────────────────────────────────── + +bool RemoveTree(const std::wstring& path) { + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) return true; // כבר אינה שם + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) return DeleteOne(path); + + bool ok = true; + WIN32_FIND_DATAW found{}; + HANDLE search = FindFirstFileW(JoinPath(path, L"*").c_str(), &found); + if (search != INVALID_HANDLE_VALUE) { + do { + const std::wstring name = found.cFileName; + if (name == L"." || name == L"..") continue; + const std::wstring child = JoinPath(path, name); + + if ((found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + ok = DeleteOne(child) && ok; + continue; + } + // ⚠️ **לא נכנסים** לקישור (junction/symlink) — מוחקים את הקישור + // עצמו. בלי זה מחיקת `Data\plugins` הייתה יכולה לזלוג דרך קישור + // אל כל מקום אחר בכונן. + if ((found.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + ok = RemoveEmptyDir(child) && ok; + } else { + ok = RemoveTree(child) && ok; + } + } while (FindNextFileW(search, &found)); + FindClose(search); + } + return RemoveEmptyDir(path) && ok; +} + +// ── קובץ החותמת ────────────────────────────────────────────────────────────── + +bool ReadStampFile(const std::wstring& path, StampFile& out) { + ScopedHandle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr)); + if (!file.valid()) return false; + + // שתי שורות: חותמת של 64 תווים ונתיב. תקרה נדיבה ובלי לשאול על הגודל + // — קובץ גדול מזה אינו קובץ החותמת שלנו. + char raw[4096]{}; + DWORD read = 0; + if (!ReadFile(file.get(), raw, sizeof(raw), &read, nullptr) || read == 0) { + return false; + } + + const std::string text(raw, read); + const size_t break_at = text.find_first_of("\r\n"); + out.stamp = text.substr(0, break_at == std::string::npos ? text.size() : break_at); + + out.installer.clear(); + if (break_at != std::string::npos) { + size_t second = text.find_first_not_of("\r\n", break_at); + if (second != std::string::npos) { + const size_t end = text.find_first_of("\r\n", second); + out.installer = Utf16( + text.substr(second, end == std::string::npos ? text.size() - second + : end - second)); + } + } + // שורה ראשונה שאינה חותמת = קובץ זר או פגום, ולא "חותמת ריקה". + return out.stamp.size() == kOverlayStampChars; +} + +bool WriteStampFile(const std::wstring& path, const StampFile& value, + std::wstring& error) { + ScopedHandle file(CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!file.valid()) { + error = L"כתיבת חותמת החבילה נכשלה:\n" + path + L"\n" + + MessageForError(GetLastError()); + return false; + } + + // `\r\n` כדי שהקובץ יהיה קריא ב-Notepad — הוא נועד גם לעין אנושית + // שמנסה להבין למה החנות פרסה שוב. + const std::string text = + value.stamp + "\r\n" + Utf8(value.installer) + "\r\n"; + if (!WriteAll(file.get(), text.data(), text.size())) { + error = L"כתיבת חותמת החבילה נכשלה:\n" + path; + return false; + } + + // ⚠️ ה-flush הזה הוא **כל** ההגנה: הקובץ הזה נכתב אחרון בכוונה, וכל + // עוד הוא לא הגיע לדיסק ההרצה הבאה תפרוס מחדש. (מול נפילת חשמל + // באמצע זה אינו ערובה מלאה — קובצי המטען עצמם אינם מסונכרנים אחד-אחד, + // כי זה היה מכפיל את זמן הפריסה על כונן USB. מול תהליך שנהרג, שזה + // התרחיש האמיתי, זה מספיק.) + FlushFileBuffers(file.get()); + return true; +} + +// ── חלון ההתקדמות ──────────────────────────────────────────────────────────── + +bool ProgressWindow::Create(HINSTANCE instance, const std::wstring& title, + const std::wstring& headline) { + INITCOMMONCONTROLSEX controls{}; + controls.dwSize = sizeof(controls); + controls.dwICC = ICC_PROGRESS_CLASS; + InitCommonControlsEx(&controls); + + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.lpfnWndProc = ProgressProc; + window_class.hInstance = instance; + window_class.hIcon = LoadIconW(instance, MAKEINTRESOURCEW(IDI_APP_ICON)); + window_class.hCursor = LoadCursorW(nullptr, IDC_ARROW); + window_class.hbrBackground = GetSysColorBrush(COLOR_WINDOW); + window_class.lpszClassName = kProgressClass; + // רישום כפול מחזיר `ERROR_CLASS_ALREADY_EXISTS` וזה בסדר — יש כאן + // חלון אחד לכל היותר בכל התהליך. + RegisterClassExW(&window_class); + + const int dpi = SystemDpi(); + const auto scale = [dpi](int dip) { return MulDiv(dip, dpi, 96); }; + const int width = scale(kBoxWidth); + const int height = scale(kBoxHeight); + const int margin = scale(kBoxMargin); + const int inner = width - margin * 2; + + RECT work{}; + if (!SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0)) { + work = {0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)}; + } + const int left = work.left + (work.right - work.left - width) / 2; + const int top = work.top + (work.bottom - work.top - height) / 2; + + // `WS_POPUP | WS_BORDER`: תיבה נקייה בלי שורת כותרת ובלי X — אין כאן + // ביטול, וכפתור סגירה שאינו עושה דבר גרוע מהיעדרו. + // `WS_EX_APPWINDOW` בכל זאת שם אותה בשורת המשימות, כדי שמי שהעביר + // אליה מיקוד בטעות יוכל לחזור. + // `WS_EX_LAYOUTRTL` הופך גם את הטקסטים וגם את כיוון המילוי של המחוון + // — הממשק כולו עברי. + window_ = CreateWindowExW(WS_EX_APPWINDOW | WS_EX_LAYOUTRTL, kProgressClass, + title.c_str(), WS_POPUP | WS_BORDER, left, top, + width, height, nullptr, nullptr, instance, nullptr); + if (window_ == nullptr) return false; + + // גופן ההודעות של המערכת — אותו גופן שדיאלוגים של ווינדוס משתמשים בו, + // ולא ה-`SYSTEM_FONT` המיושן שהוא ברירת המחדל של חלון גולמי. + NONCLIENTMETRICSW metrics{}; + metrics.cbSize = sizeof(metrics); + if (SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, sizeof(metrics), &metrics, + 0)) { + font_ = CreateFontIndirectW(&metrics.lfMessageFont); + } + + const auto add_label = [&](int y, int label_height, const std::wstring& text) { + HWND label = CreateWindowExW(0, L"STATIC", text.c_str(), + WS_CHILD | WS_VISIBLE | SS_LEFT, margin, + scale(y), inner, scale(label_height), window_, + nullptr, instance, nullptr); + if (label != nullptr && font_ != nullptr) { + SendMessageW(label, WM_SETFONT, reinterpret_cast(font_), TRUE); + } + return label; + }; + + add_label(26, 22, headline); + bar_ = CreateWindowExW(0, PROGRESS_CLASSW, nullptr, WS_CHILD | WS_VISIBLE, + margin, scale(62), inner, scale(16), window_, nullptr, + instance, nullptr); + if (bar_ != nullptr) { + SendMessageW(bar_, PBM_SETRANGE32, 0, kProgressRange); + } + detail_ = add_label(92, 20, L""); + + ShowWindow(window_, SW_SHOW); + // `UpdateWindow` מכריח ציור **מיד**, בלי להמתין לסבב ההודעות הבא: כל + // הנקודה בחלון הזה היא שהוא נראה לפני שהפריסה מתחילה לחסום. + UpdateWindow(window_); + return true; +} + +void ProgressWindow::Update(uint64_t done, uint64_t total) { + if (window_ == nullptr) return; + + const int percent = + total == 0 ? 100 : static_cast((done * 100) / total); + if (percent != shown_percent_) { + shown_percent_ = percent; + if (bar_ != nullptr) { + const int position = + total == 0 ? kProgressRange + : static_cast((done * kProgressRange) / total); + SendMessageW(bar_, PBM_SETPOS, static_cast(position), 0); + } + if (detail_ != nullptr) { + // רק אחוז, בלי שם הקובץ ובלי MB: מספר אחד שמתקדם הוא כל מה + // שמשתמש קורא בחלון שנעלם אחרי כמה שניות, וטקסט מעורב עברית-לטינית + // בחלון ממורר הוא בדיוק המקום שבו הכיווניות יוצאת עקומה. + wchar_t text[64]; + swprintf(text, 64, L"%d%%", percent); + SetWindowTextW(detail_, text); + } + } + Pump(); +} + +void ProgressWindow::Pump() { + // הפריסה רצה על ה-thread הזה וחוסמת אותו. בלי ריקון התור ווינדוס + // מסמנת את החלון כ"אינו מגיב", מכהה אותו, ומציירת עליו את מה שהיה + // מאחוריו. + // + // למה כאן ולא thread עובד: כל מה שהחלון עושה הוא להראות מספר, ואין + // בו שום פעולה שהמשתמש יכול לעשות. thread שני היה מוסיף סנכרון + // ומסלולי סגירה בלי להוסיף דבר שנראה על המסך. + MSG message; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } +} + +void ProgressWindow::Close() { + if (window_ != nullptr) { + DestroyWindow(window_); + window_ = nullptr; + bar_ = nullptr; + detail_ = nullptr; + // ההריסה מייצרת הודעות; בלי ריקון התור החלון נשאר מצויר על המסך עד + // שמישהו אחר יריץ סבב הודעות — וזה יקרה רק אחרי שה-WebView2 יעלה. + Pump(); + } + if (font_ != nullptr) { + DeleteObject(font_); + font_ = nullptr; + } +} + +} // namespace otz diff --git a/native/src/overlay.h b/native/src/overlay.h new file mode 100644 index 0000000..110f070 --- /dev/null +++ b/native/src/overlay.h @@ -0,0 +1,171 @@ +// המראה שנוסעת **בתוך** קובץ ההרצה: זיהוי, פריסה לדיסק, ועותק רזה. +// +// ── מה זה פותר ─────────────────────────────────────────────────────────────── +// התוכנה עצמה היא ‎0.5MB, אבל המראה שהיא מסנכרנת היא ~‎96MB — וזה בדיוק +// מה שאי אפשר להוריד במחשב מנותק. לכן קיימת **חבילה**: אותו exe בדיוק, +// עם המראה משורשרת לסופו. בהרצה הראשונה הוא פורס אותה לתיקייה שלידו, +// כותב לצדו עותק רזה של עצמו (בלי המטען), מפעיל אותו ויוצא. +// +// מכאן והלאה מה שרץ הוא ה-exe הרזה, והחבילה הגדולה היא קובץ מיותר +// שהמשתמש יכול למחוק — הממשק מציע לו את זה (ראו `installerPath` +// שב-`app.info`). +// +// ── הפורמט ─────────────────────────────────────────────────────────────────── +// נקבע ב-native/tools/pack_overlay.mjs, וכל שינוי בו חייב לקרות בשני +// המקומות: +// +// [ ה-exe המקורי, ללא שינוי ] +// [ תוכן הקבצים, ברצף, בסדר האינדקס, **בלי דחיסה** ] +// [ אינדקס: count(u32), ואז לכל קובץ path_len(u32) size(u64) path(utf8) ] +// [ footer: 96 בייט קבועים בסוף הקובץ ] +// +// ה-footer בסוף ובגודל קבוע כדי שהזיהוי יהיה קפיצה אחת: קובץ שאין בו +// חבילה עולה כאן קריאה של 96 בייט, ולא סריקה. +// +// ── למה אין תיקייה זמנית ואין דחיסה ────────────────────────────────────────── +// **דחיסה** אינה מחזירה כאן דבר: ה-`.otzplugin` הם ZIP והתמונות הן +// webp/png. גולמי פירושו שהפריסה היא העתקה **בזרימה** בחוצץ קבוע, ולא +// פרישה של ‎96MB לזיכרון. +// +// **תיקייה זמנית** הייתה דורשת ‎96MB פנויים נוספים על כונן נייד, וזה לא +// מחיר סביר. האטומיות מגיעה מסדר הפעולות במקום: קובץ החותמת נמחק לפני +// הפריסה ונכתב רק אחרי שהקובץ האחרון נחת. פריסה שנקטעה = אין חותמת = +// ההרצה הבאה עושה אותה מחדש, וזה כל מה שצריך. +#pragma once + +// מביא את `windows.h` — [ProgressWindow] מחזיק `HWND` ו-`HINSTANCE`. +#include "common.h" + +#include +#include +#include + +namespace otz { + +// חייב להתאים ל-`MAGIC` ו-`FOOTER_SIZE` שב-native/tools/pack_overlay.mjs. +inline constexpr char kOverlayMagic[8] = {'O', 'T', 'Z', 'B', 'N', 'D', 'L', '1'}; +inline constexpr uint64_t kOverlayFooterSize = 96; + +// אורך החותמת: SHA-256 בייצוג hex. +inline constexpr size_t kOverlayStampChars = 64; + +// קובץ החותמת, בתוך `Data\`. שתי שורות — ראו [StampFile]. +inline constexpr wchar_t kStampFileName[] = L"bundle.stamp"; + +// מה שה-footer מספר על החבילה. נקרא פעם אחת בעלייה. +struct OverlayInfo { + // גודל ה-exe **בלי** המטען — כלומר גם מספר הבייטים שהעותק הרזה מכיל. + uint64_t data_offset = 0; + + // איפה מתחיל האינדקס, וכמה הוא. גם סוף אזור התוכן. + uint64_t index_offset = 0; + uint64_t index_size = 0; + + // גודל התוכן עצמו — `index_offset - data_offset`. זה המכנה של + // ההתקדמות, ולכן הוא נגזר כאן פעם אחת ולא בכל קריאה. + uint64_t payload_size = 0; + + // מזהה החבילה: 64 תווי hex שנגזרו מהנתיבים ומהתוכן (ולא מהזמן), כדי + // ששתי אריזות של אותה מראה ייתנו אותה חותמת. זה מה שמשווים למה שרשום + // ב-`Data\bundle.stamp` כדי לדעת אם התיקייה שליד ה-exe היא כבר של + // החבילה הזאת. + std::string stamp; +}; + +// מדווח כמה בייטים מהתוכן כבר נכתבו. נקרא **הרבה** — אחרי כל גוש, ולא +// אחרי כל קובץ — כדי שקובץ בודד גדול לא ייראה כתקיעה. +// +// אין כאן שם הקובץ הנוכחי במכוון: שם שמתחלף עשרות פעמים בשנייה הוא +// רעש, וכשל ממילא חוזר עם הנתיב בתוך הודעת השגיאה. +using ProgressFn = std::function; + +// קורא את ה-footer ומאמת אותו. **קובץ בלי חבילה אינו שגיאה** — זה המצב +// של ה-exe הרזה, כלומר של רוב ההרצות — ולכן `false` כאן שקט לגמרי. +bool HasOverlay(const std::wstring& exe_path, OverlayInfo& out); + +// פורס את המטען אל [data_dir], **בזרימה**: חוצץ קבוע אחד, בלי שאף קובץ +// (ובוודאי לא המטען כולו) ייטען לזיכרון. תיקיות נוצרות לפי הצורך. +// +// אינו מוחק דבר — הניקוי שלפניו הוא החלטה של הקורא, ראו main.cpp. +bool ExtractOverlay(const std::wstring& exe_path, const OverlayInfo& info, + const std::wstring& data_dir, const ProgressFn& on_progress, + std::wstring& error); + +// כותב ל-[dest] את [data_offset] הבייטים הראשונים של [exe_path] — כלומר +// את ה-exe בלי המטען. +// +// ⚠️ בווינדוס **קריאה** מקובץ ההרצה של התהליך הרץ מותרת, אבל כתיבה +// אליו אינה. הקורא חייב לוודא ש-[dest] אינו אנחנו עצמנו; כאן זה לא +// נבדק, כי מהמקום הזה כבר אין מה לעשות עם התשובה. +bool WriteSlimCopy(const std::wstring& exe_path, uint64_t data_offset, + const std::wstring& dest, std::wstring& error); + +// מוחק תיקייה על כל תוכנה. תיקייה שאינה קיימת היא הצלחה — המצב המבוקש +// הושג. +// +// ⚠️ יושב כאן ולא ב-fsapi בכוונה: `fsapi.h` הוא **מה שהגשר חושף ל-JS**, +// ומחיקה רקורסיבית אינה פעולה שצריכה להיות זמינה לממשק. המשתמש היחיד +// שלה הוא ההחלפה הסיטונית של המראה. +bool RemoveTree(const std::wstring& path); + +// מריץ את [exe_path] ולא ממתין לו. זו מסירת השרביט לעותק הרזה: החבילה +// יוצאת מיד אחריה. +bool LaunchAndForget(const std::wstring& exe_path, std::wstring& error); + +// תוכן `Data\bundle.stamp` — שתי שורות, ומכאן שאין כאן פרסור אמיתי. +// +// למה קובץ טקסט שטוח ולא JSON: הצד ה-C++ אינו מפרסר JSON בכלל (ראו +// common.h), והקובץ הזה נקרא **לפני** שהממשק עולה — כלומר לפני שיש מי +// שיפרסר בשבילנו. +struct StampFile { + // שורה 1 — חותמת החבילה שממנה נפרסה התיקייה. + std::string stamp; + + // שורה 2 — הנתיב המלא של החבילה שפרסה אותה, כפי שהיה בזמן הפריסה. + // ריק בקובץ שנכתב בגרסה שלא רשמה אותו. + std::wstring installer; +}; + +bool ReadStampFile(const std::wstring& path, StampFile& out); +bool WriteStampFile(const std::wstring& path, const StampFile& value, + std::wstring& error); + +// חלון ההתקדמות של הפריסה — Win32 חשוף, **בלי WebView2**. +// +// ⚠️ למה לא הממשק הרגיל: הפריסה קורית לפני שה-WebView2 עולה בכלל (הוא +// לוקח מאות ms בעצמו, וממילא הממשק צריך `Data\` פרוסה כדי להראות משהו). +// ‎96MB לכונן USB הם 3-20 שניות, ומסך ריק לאורך זמן כזה נראה כמו תוכנה +// תקועה. חלון Win32 עם progress bar עולה בפחות מ-10ms. +// +// אין כפתור סגירה ואין ביטול: פעולה של פחות מדקה שאין לה מצב ביניים +// מזיק (ראו ההסבר על החותמת בראש הקובץ) לא מרוויחה מהם. +class ProgressWindow { + public: + ~ProgressWindow() { Close(); } + + // יוצר ומציג. `false` = לא נוצר — הקורא ממשיך בלי חלון ולא נכשל + // בגללו: פריסה בלי חיווי עדיפה על אי-פריסה. + bool Create(HINSTANCE instance, const std::wstring& title, + const std::wstring& headline); + + // מעדכן את המחוון **וממחזר את תור ההודעות**. ראו [Pump] ב-overlay.cpp. + void Update(uint64_t done, uint64_t total); + + void Close(); + + private: + // מרוקן את תור ההודעות של החלון. הפריסה חוסמת את ה-thread הזה, ובלי + // זה ווינדוס הייתה מסמנת את החלון כ"אינו מגיב" ומציירת עליו רפאים. + void Pump(); + + HWND window_ = nullptr; + HWND bar_ = nullptr; + HWND detail_ = nullptr; + HFONT font_ = nullptr; + + // האחוז שכבר מצויר. עדכון רק כשהוא משתנה — אחרת כל גוש של ‎1MB היה + // מפעיל ציור מחדש של אותו טקסט בדיוק. + int shown_percent_ = -1; +}; + +} // namespace otz diff --git a/native/test/compatibility.test.js b/native/test/compatibility.test.js index 59041f0..93d7767 100644 --- a/native/test/compatibility.test.js +++ b/native/test/compatibility.test.js @@ -81,12 +81,19 @@ describe('resolveTargets', () => { assert.equal(targets[0].version, '3.0.0'); }); - it('בילד לכל גרסת אוצריא שהכונן נושא', () => { + it('בילד לכל גרסת יעד של אוצריא', () => { const targets = resolveTargets(entries, ['0.9.55', '0.9.70']); assert.deepEqual(targets.map((t) => t.version).sort(), ['2.0.0', '3.0.0']); }); + it('מוחזר ממוין יורד, ולא בסדר הגרסאות שנמסרו', () => { + // הצרכנים בוחרים מהתוצאה את "הגבוה שמתאים למחשב הזה", ולכן הסדר הוא + // חלק מהחוזה ולא פרט מימוש. + const targets = resolveTargets(entries, ['0.9.55', '0.9.70']); + assert.deepEqual(targets.map((t) => t.version), ['3.0.0', '2.0.0']); + }); + it('בלי כפילות כששתי הגרסאות נפתרות לאותו בילד', () => { const targets = resolveTargets(entries, ['0.9.60', '0.9.70']); assert.equal(targets.length, 1); diff --git a/native/test/mirror_changed.test.js b/native/test/mirror_changed.test.js new file mode 100644 index 0000000..0b221a3 --- /dev/null +++ b/native/test/mirror_changed.test.js @@ -0,0 +1,249 @@ +// "האם יש מה לפרסם?" — הגלאי שקובע אם הג'וב השבועי מעלה חבילה של ‎96MB +// או יוצא בשקט. +// +// ── למה זה נבדק בקודי יציאה ────────────────────────────────────────────────── +// הקורא היחיד של הכלי הזה הוא ה-workflow, והוא קורא **רק** את קוד +// היציאה. לכן זה מה שנבדק כאן, ולא הודעות: 0 = יש שינוי, 1 = אין שינוי, +// 2 = שימוש שגוי. +// +// ⚠️ הכיוון שהכי כדאי להגן עליו הוא דווקא "אין שינוי": כלי שמכריז על +// שינוי בכל ריצה אינו נראה שבור — הוא פשוט מפרסם ‎96MB זהים כל שבוע, וזה +// מתגלה רק בחשבון. לכן רוב הבדיקות כאן הן על מה ש**אינו** נחשב שינוי. + +import assert from 'node:assert/strict'; +import {after, before, describe, it} from 'node:test'; +import {execFile} from 'node:child_process'; +import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const TOOL = join(here, '..', 'tools', 'mirror_changed.mjs'); + +/** קודי היציאה, בשמות — כדי שהבדיקות יקראו כמו החוזה. */ +const PUBLISH = 0; +const NOTHING_TO_PUBLISH = 1; +const MISUSE = 2; + +let root; +const at = (...parts) => join(root, ...parts); + +function run(...args) { + return new Promise((resolve) => { + execFile(process.execPath, [TOOL, ...args], {encoding: 'utf8'}, + (error, stdout, stderr) => { + resolve({code: error ? (error.code ?? 1) : 0, stdout, stderr}); + }); + }); +} + +/** קטלוג בצורה שהמראה מפיקה בפועל — עם חותמת זמן ועם מונים. */ +const catalog = ({lastSync = '2026-01-01T00:00:00Z', version = '1.0.0', + downloads = 12, name = 'תוסף לדוגמה'} = {}) => ({ + lastSync, + plugins: [{ + id: 'com.example.plugin', + name, + version, + downloadCount: downloads, + ratingAvg: 4.5, + file: 'plugins/com.example.plugin/plugin-1.0.otzplugin', + }], +}); + +const writeJson = async (path, value) => + await writeFile(path, JSON.stringify(value), 'utf8'); + +/** מראת קבצים קטנה עם תת-תיקייה, כמו `Data\plugins\\`. */ +async function makePlugins(dir, {size = 40} = {}) { + await mkdir(join(dir, 'com.example.plugin'), {recursive: true}); + await writeFile(join(dir, 'com.example.plugin', 'plugin-1.0.otzplugin'), + Buffer.alloc(size, 1)); + await writeFile(join(dir, 'index.json'), '{}'); + return dir; +} + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'otzaria-changed-')); +}); + +after(async () => { + await rm(root, {recursive: true, force: true}); +}); + +describe('mirror_changed — מה שאינו נחשב שינוי', () => { + let plugins; + let inventoryPath; + + before(async () => { + plugins = await makePlugins(at('plugins')); + inventoryPath = at('before.txt'); + const listed = await run('--inventory', plugins); + await writeFile(inventoryPath, listed.stdout, 'utf8'); + }); + + it('רק lastSync זז — אין מה לפרסם', async () => { + // ⚠️ זו הנקודה שכל הכלי נשען עליה. `lastSync` הוא חותמת זמן והוא זז + // בכל ריצה מעצם הגדרתו; השוואה שכוללת אותו הייתה מכריזה על שינוי + // תמיד, והכלי כולו היה חסר משמעות. + await writeJson(at('a.json'), catalog({lastSync: '2026-01-01T00:00:00Z'})); + await writeJson(at('b.json'), catalog({lastSync: '2026-09-04T11:22:33Z'})); + const result = await run(at('a.json'), at('b.json'), inventoryPath, plugins); + assert.equal(result.code, NOTHING_TO_PUBLISH, result.stdout + result.stderr); + }); + + it('מונה הורדות שזז — אין מה לפרסם', async () => { + // המונים נשמרים בקטלוג כי הממשק מציג אותם, אבל הם משתנים באתר כמה + // פעמים ביום. בלי נטרול שלהם הג'וב היה מפרסם בכל ריצה — תמיד מישהו + // הוריד תוסף בינתיים. + await writeJson(at('c.json'), catalog({downloads: 12})); + await writeJson(at('d.json'), catalog({downloads: 4137})); + const result = await run(at('c.json'), at('d.json'), inventoryPath, plugins); + assert.equal(result.code, NOTHING_TO_PUBLISH, result.stdout + result.stderr); + }); + + it('שני הצדדים זהים לחלוטין — אין מה לפרסם', async () => { + await writeJson(at('e.json'), catalog()); + await writeJson(at('f.json'), catalog()); + const result = await run(at('e.json'), at('f.json'), inventoryPath, plugins); + assert.equal(result.code, NOTHING_TO_PUBLISH); + }); + + it('אין רשימת קבצים "לפני" — הקטלוג לבדו מכריע', async () => { + // הרשימה נכתבת בג'וב אחרי פרישת החבילה הקודמת; אם היא חסרה, אין + // ממה להסיק שהקבצים השתנו — ובוודאי לא להסיק שכן. + await writeJson(at('g.json'), catalog()); + await writeJson(at('h.json'), catalog()); + const result = + await run(at('g.json'), at('h.json'), at('אין-רשימה.txt'), plugins); + assert.equal(result.code, NOTHING_TO_PUBLISH); + }); +}); + +describe('mirror_changed — מה שכן נחשב שינוי', () => { + let plugins; + let inventoryPath; + + before(async () => { + plugins = await makePlugins(at('plugins2')); + inventoryPath = at('before2.txt'); + await writeFile(inventoryPath, + (await run('--inventory', plugins)).stdout, 'utf8'); + }); + + it('גרסת תוסף שהשתנתה — מפרסמים', async () => { + await writeJson(at('i.json'), catalog({version: '1.0.0'})); + await writeJson(at('j.json'), catalog({version: '1.1.0'})); + const result = await run(at('i.json'), at('j.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); + + it('שם תוסף שהשתנה — מפרסמים', async () => { + // ההשוואה היא על הקטלוג כולו ולא על רשימת קבצים: תיאור או שם + // שהתעדכנו הם מה שהמשתמש המנותק רואה, וזו סיבה מספקת לחבילה חדשה. + await writeJson(at('k.json'), catalog({name: 'תוסף לדוגמה'})); + await writeJson(at('l.json'), catalog({name: 'תוסף בשם חדש'})); + const result = await run(at('k.json'), at('l.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); + + it('אין קטלוג קודם כלל — פרסום ראשון', async () => { + // ⚠️ ברירת המחדל היא לפרסם ולא להימנע: "אין מול מה להשוות" חייב + // להסתיים בחבילה, אחרת הפרסום הראשון לעולם אינו קורה. + await writeJson(at('m.json'), catalog()); + const result = + await run(at('אין-קטלוג.json'), at('m.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); + + it('קטלוג קודם פגום — מפרסמים', async () => { + // JSON שבור אינו ניתן להשוואה, וההנחה הבטוחה היא שהמראה שונה. + await writeFile(at('שבור.json'), '{לא JSON', 'utf8'); + await writeJson(at('n.json'), catalog()); + const result = + await run(at('שבור.json'), at('n.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); + + it('הקטלוג זהה אבל קובץ במראה גדל — מפרסמים', async () => { + // ⚠️ הקטלוג לבדו אינו מספיק: הורדה שנכשלה בריצה קודמת יכולה להשאיר + // קובץ חסר או חלקי בלי שהקטלוג ישקף זאת, והמנותק הוא זה שמגלה. + await writeJson(at('o.json'), catalog()); + await writeJson(at('p.json'), catalog()); + await writeFile(join(plugins, 'com.example.plugin', 'plugin-1.0.otzplugin'), + Buffer.alloc(999, 1)); + const result = await run(at('o.json'), at('p.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); + + it('הקטלוג זהה אבל קובץ נעלם מהמראה — מפרסמים', async () => { + await writeJson(at('q.json'), catalog()); + await writeJson(at('r.json'), catalog()); + await rm(join(plugins, 'index.json')); + const result = await run(at('q.json'), at('r.json'), inventoryPath, plugins); + assert.equal(result.code, PUBLISH); + }); +}); + +describe('mirror_changed — שימוש שגוי', () => { + it('בלי ארגומנטים — יציאה 2', async () => { + // 2 מובחן משניהם בכוונה: ה-workflow אינו אמור לפרש הפעלה שגויה + // כ"אין מה לפרסם" ולהמשיך בשקט. + const result = await run(); + assert.equal(result.code, MISUSE); + assert.match(result.stderr, /usage/); + }); + + it('ארגומנטים חלקיים — יציאה 2', async () => { + const result = await run(at('a.json'), at('b.json')); + assert.equal(result.code, MISUSE); + }); + + it('הקטלוג שנבנה אינו קריא — יציאה 2 ולא "אין שינוי"', async () => { + // ⚠️ קטלוג שלא נבנה הוא כשל של הריצה, לא "המראה לא השתנתה". יציאה 1 + // כאן הייתה מסתירה סנכרון שנפל. + await writeJson(at('s.json'), catalog()); + const result = + await run(at('s.json'), at('אין.json'), at('before.txt'), at('plugins')); + assert.equal(result.code, MISUSE); + }); + + it('--inventory בלי תיקייה — יציאה 2', async () => { + const result = await run('--inventory'); + assert.equal(result.code, MISUSE); + assert.match(result.stderr, /usage/); + }); +}); + +describe('mirror_changed --inventory', () => { + // הרשימה נוצרת פעמיים באותה ריצה של הג'וב — פעם אחרי פרישת החבילה + // הקודמת ופעם אחרי הסנכרון. שני מימושים שונים לא היו ניתנים להשוואה, + // ולכן זהו אותו קוד בדיוק ולכן הוא נבדק בנפרד. + + it('שם יחסי, מפריד "/", גודל אחרי טאב, וממוין', async () => { + const dir = await makePlugins(at('inv'), {size: 40}); + const result = await run('--inventory', dir); + assert.equal(result.code, PUBLISH); + assert.deepEqual(result.stdout.split('\n'), [ + 'com.example.plugin/plugin-1.0.otzplugin\t40', + 'index.json\t2', + ]); + }); + + it('תיקייה שאינה קיימת — פלט ריק ולא כשל', async () => { + // הרשימה "לפני" מיוצרת גם בפרסום הראשון, שבו אין עדיין תיקייה. + const result = await run('--inventory', at('אין-תיקייה')); + assert.equal(result.code, PUBLISH); + assert.equal(result.stdout, ''); + }); + + it('שתי הרצות על אותה תיקייה נותנות אותו פלט', async () => { + // אם הפלט אינו יציב, ההשוואה בין "לפני" ל"אחרי" מכריזה על שינוי + // בכל ריצה — בדיוק הכשל שהכלי נועד למנוע. + const dir = at('inv'); + const first = await run('--inventory', dir); + const second = await run('--inventory', dir); + assert.equal(first.stdout, second.stdout); + }); +}); diff --git a/native/test/node_host.test.js b/native/test/node_host.test.js new file mode 100644 index 0000000..850dc1e --- /dev/null +++ b/native/test/node_host.test.js @@ -0,0 +1,262 @@ +// שכבת ה-fs שה-CI מריץ בה את `PluginMirrorSync` — מול מערכת קבצים אמיתית. +// +// ── למה יש כאן בדיקות בכלל ─────────────────────────────────────────────────── +// `node_host.mjs` אינו מחליט דבר על תוספים; הוא **חיקוי של חוזה ה-host** +// (`native/src/fsapi.cpp`). כל פרט שנקבע שם ולא הועתק לכאן במדויק מייצר +// את הכשל הגרוע ביותר שיש לפרויקט הזה: CI שמצליח, מפרסם חבילה, והתוכנה +// אצל המשתמש מתנהגת אחרת. ולהפך — הקוד שקורא לשכבה הזאת נכתב מול ה-host, +// ולכן הוא נשען על הפרטים האלה בלי לבדוק אותם. +// +// הפרטים שנבדקים כאן הם בדיוק אלה שמסומנים "כמו ב-host" במקור. + +import assert from 'node:assert/strict'; +import {after, before, describe, it} from 'node:test'; +import {mkdtemp, mkdir, readdir, readFile, rm, writeFile} + from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; + +import {fs} from '../tools/node_host.mjs'; + +/** תיקיית עבודה זמנית — הבדיקות כאן נוגעות בדיסק אמיתי ולא ב-mock. */ +let root; +const at = (...parts) => join(root, ...parts); + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'otzaria-host-')); +}); + +after(async () => { + await rm(root, {recursive: true, force: true}); +}); + +describe('list — הסריקה שכל מפת המראה נבנית ממנה', () => { + it('תיקייה שאינה קיימת מחזירה רשימה ריקה ולא זורקת', async () => { + // ⚠️ כמו ב-host. הקורא (`mirror_store.js`) סורק תיקיית תוסף לפני + // שהיא קיימת — בסנכרון הראשון אין אף אחת מהן. חריג כאן היה מפיל את + // הסנכרון כולו במקום להתחיל אותו. + assert.deepEqual(await fs.list(at('אין-כזאת')), []); + }); + + it('נתיב שהוא קובץ ולא תיקייה מחזיר אף הוא רשימה ריקה', async () => { + // ENOTDIR ולא ENOENT — קוד שגיאה אחר, אותה משמעות לקורא: אין כאן + // תוכן לסרוק. + const file = at('קובץ.txt'); + await writeFile(file, 'x'); + assert.deepEqual(await fs.list(file), []); + }); + + it('תיקייה ריקה — רשימה ריקה, וזה אינו כשל', async () => { + const dir = at('ריקה'); + await mkdir(dir); + assert.deepEqual(await fs.list(dir), []); + }); + + it('כל רשומה היא {name, dir, size, modified}, ותיקיות מסומנות', async () => { + // ארבעת השדות האלה הם כל מה שהקורא מקבל, ו-`dir` הוא מה שמפריד בין + // "תיקיית תוסף" לבין "קובץ בילד". סימון שגוי שלו היה גורר ניסיון + // לקרוא תיקייה כקובץ .otzplugin. + const dir = at('מראה'); + await mkdir(join(dir, 'תת-תיקייה'), {recursive: true}); + await writeFile(join(dir, 'בילד.otzplugin'), 'שנים עשר'); + + const entries = (await fs.list(dir)).sort((a, b) => + a.name < b.name ? -1 : 1); + assert.equal(entries.length, 2); + + const [file, sub] = entries; + assert.equal(file.name, 'בילד.otzplugin'); + assert.equal(file.dir, false); + // ⚠️ בבייטים ולא בתווים: 'שנים עשר' הוא 8 תווים ו-15 בייטים ב-UTF-8. + // גודל שנמדד בתווים היה מכריז על "התוסף השתנה" בכל סנכרון. + assert.equal(file.size, Buffer.byteLength('שנים עשר', 'utf8')); + assert.ok(file.modified > 0, 'חותמת הזמן חייבת להיות מספר של ממש'); + + assert.equal(sub.name, 'תת-תיקייה'); + assert.equal(sub.dir, true); + }); + + it('הגודל אינו 0 — הנתיב הפנימי ל-stat חייב להיות נתיב אמיתי', async () => { + // ⚠️ זו הרגרסיה היקרה: `list` מצרפת את שם הרשומה לנתיב כדי לעשות + // עליה stat, וצירוף שאינו של הפלטפורמה נכשל בשקט ומחזיר `size: 0` + // לכל קובץ. התוצאה היא מראה שנראית מלאה ושכל קבציה "ריקים", וההשוואה + // מול הקטלוג הייתה מורידה הכול מחדש בכל ריצה. + const dir = at('גדלים'); + await mkdir(dir); + await writeFile(join(dir, 'a.bin'), Buffer.alloc(4096, 7)); + const [entry] = await fs.list(dir); + assert.equal(entry.size, 4096); + }); +}); + +describe('remove — "המצב המבוקש הושג"', () => { + it('קובץ שאינו קיים הוא הצלחה', async () => { + // כמו ב-host. הקורא גורע בילד ישן אחרי שהחדש ירד; אם ריצה קודמת + // נקטעה אחרי המחיקה ולפני עדכון הקטלוג, המחיקה תתבצע שוב — וזה + // אינו מצב שגיאה. + assert.equal(await fs.remove(at('מעולם-לא-היה.otzplugin')), true); + }); + + it('קובץ קיים נמחק', async () => { + const file = at('למחיקה.bin'); + await writeFile(file, 'x'); + assert.equal(await fs.remove(file), true); + assert.equal(await fs.kind(file), 'none'); + }); +}); + +describe('writeText/readText — הקטלוג נכתב ונקרא בעברית', () => { + it('הלוך-חזור על טקסט עברי (UTF-8)', async () => { + // כל שמות התוספים בקטלוג הם עברית. קידוד שגוי כאן אינו מתגלה + // כשגיאה אלא כשמות משובשים בכל דף בחנות. + const path = at('קטלוג', 'catalog.json'); + const text = JSON.stringify({name: 'תוסף לדוגמה', note: 'שלום ✡'}); + assert.equal(await fs.writeText(path, text), true); + assert.equal(await fs.readText(path), text); + // ובאמת UTF-8 על הדיסק, ולא ברירת מחדל אחרת של המערכת: + assert.equal((await readFile(path)).toString('utf8'), text); + }); + + it('תיקיית האב נוצרת מאליה', async () => { + // כמו ב-host: הקורא כותב קטלוג לתיקייה שטרם קיימת בפרסום הראשון. + const path = at('חדשה', 'עמוק', 'יותר', 'file.json'); + await fs.writeText(path, '{}'); + assert.equal(await fs.readText(path), '{}'); + }); + + it('לא נשאר קובץ ‎.tmp אחרי כתיבה מוצלחת', async () => { + // הכתיבה היא דרך קובץ זמני ושינוי שם, כדי שקטלוג לא יישאר חצי-כתוב. + // אם ה-tmp שורד, הוא נכנס לחבילה ומתגלה כקובץ זר אצל המשתמש. + const dir = at('זמניים'); + await fs.writeText(join(dir, 'catalog.json'), '{"a":1}'); + assert.deepEqual(await readdir(dir), ['catalog.json']); + }); + + it('כתיבה חוזרת דורסת ואינה מוסיפה', async () => { + const path = at('דריסה.json'); + await fs.writeText(path, 'ארוך מאוד מאוד'); + await fs.writeText(path, 'קצר'); + assert.equal(await fs.readText(path), 'קצר'); + }); +}); + +describe('kind — הבדיקה שמחליפה "האם קיים"', () => { + it("'file' / 'dir' / 'none'", async () => { + // שלושה ערכים ולא בוליאני: הקורא צריך להבחין בין "אין" לבין "יש, + // אבל זה תיקייה" — שגיאת המשתמש הנפוצה של תיקייה בשם של קובץ. + const file = at('kind.txt'); + const dir = at('kind-dir'); + await writeFile(file, 'x'); + await mkdir(dir); + assert.equal(await fs.kind(file), 'file'); + assert.equal(await fs.kind(dir), 'dir'); + assert.equal(await fs.kind(at('kind-none')), 'none'); + }); + + it('fileExists/dirExists נגזרים ממנו ואינם זורקים', async () => { + assert.equal(await fs.fileExists(at('kind.txt')), true); + assert.equal(await fs.fileExists(at('kind-dir')), false); + assert.equal(await fs.dirExists(at('kind-dir')), true); + assert.equal(await fs.dirExists(at('kind-none')), false); + }); +}); + +describe('readBase64 — קריאת קטע, מה ש-manifest_reader נשען עליו', () => { + // ⚠️ זו הפונקציה היחידה שמאפשרת לקרוא `manifest.json` מתוך .otzplugin + // בלי לפרוש את ה-ZIP: הקורא קופץ לספרייה המרכזית בסוף הקובץ ומושך + // ממנה קטעים. offset או אורך שאינם מדויקים מייצרים "תוסף פגום" על + // קובץ תקין לגמרי. + const payload = Buffer.from('0123456789abcdef', 'latin1'); + let path; + + before(async () => { + path = at('קטעים.bin'); + await writeFile(path, payload); + }); + + const decode = (b64) => Buffer.from(b64, 'base64'); + + it('קטע באמצע — בדיוק הבייטים שנתבקשו', async () => { + assert.deepEqual(decode(await fs.readBase64(path, 4, 6)), + payload.subarray(4, 10)); + }); + + it('מההתחלה ומהסוף', async () => { + assert.deepEqual(decode(await fs.readBase64(path, 0, 16)), payload); + assert.deepEqual(decode(await fs.readBase64(path, 15, 1)), + payload.subarray(15)); + }); + + it('בקשה שחורגת מסוף הקובץ מקוצצת ואינה מרפדת באפסים', async () => { + // ⚠️ ריפוד היה נראה כמו ZIP עם זנב אפסים, והקורא היה מחפש בו חתימה + // שאינה שם. ה-host מחזיר את מה שנקרא בפועל, ולא יותר. + assert.deepEqual(decode(await fs.readBase64(path, 12, 100)), + payload.subarray(12)); + assert.equal(await fs.readBase64(path, 999, 10), ''); + }); + + it('אורך 0 מחזיר מחרוזת ריקה', async () => { + assert.equal(await fs.readBase64(path, 0, 0), ''); + }); + + it('offset ואורך מתקבלים גם כמחרוזות', async () => { + // הערכים חוצים את הגשר כ-JSON, ומגיעים לא פעם כמחרוזת. `Number()` + // במקור הוא בדיוק בשביל זה — בלעדיו `read` היה מקבל NaN. + assert.deepEqual(decode(await fs.readBase64(path, '4', '6')), + payload.subarray(4, 10)); + }); +}); + +describe('rename/copy — יעד בתיקייה שטרם קיימת', () => { + // כמו ב-host: ההורדה נכתבת לקובץ זמני ואז עוברת למקומה תחת + // `plugins\\`, ותיקיית התוסף אינה קיימת בהורדה הראשונה. בלי יצירת + // האב, כל תוסף חדש היה נכשל. + + it('rename יוצר את תיקיית האב', async () => { + const from = at('מקור-rename.bin'); + const to = at('יעד', 'תוסף', 'plugin-1.0.otzplugin'); + await writeFile(from, 'תוכן'); + assert.equal(await fs.rename(from, to), true); + assert.equal(await fs.readText(to), 'תוכן'); + assert.equal(await fs.kind(from), 'none', 'המקור אמור להיעלם'); + }); + + it('copy יוצר את תיקיית האב ומשאיר את המקור', async () => { + const from = at('מקור-copy.bin'); + const to = at('יעד-העתקה', 'עוד', 'copy.bin'); + await writeFile(from, 'תוכן'); + assert.equal(await fs.copy(from, to), true); + assert.equal(await fs.readText(to), 'תוכן'); + assert.equal(await fs.kind(from), 'file'); + }); +}); + +describe('mkdirs', () => { + it('יוצר שרשרת שלמה, וקריאה חוזרת אינה כשל', async () => { + // כמו ב-host: התיקייה כבר קיימת בכל ריצה שנייה ואילך. + const dir = at('שרשרת', 'א', 'ב'); + assert.equal(await fs.mkdirs(dir), true); + assert.equal(await fs.mkdirs(dir), true); + assert.equal(await fs.kind(dir), 'dir'); + }); +}); + +describe('stat', () => { + it('מחזיר {size, modified} בלבד, ו-modified שלם', async () => { + // ⚠️ `modified` נכתב לקטלוג ומושווה שם. ערך שבור (`mtimeMs` עם שבר) + // אינו שווה לעצמו אחרי מסע הלוך-חזור ב-JSON בגרסאות שונות, וכל + // השוואה כזאת הייתה מכריזה על שינוי. + const path = at('stat.bin'); + await writeFile(path, Buffer.alloc(123)); + const info = await fs.stat(path); + assert.deepEqual(Object.keys(info).sort(), ['modified', 'size']); + assert.equal(info.size, 123); + assert.equal(Number.isInteger(info.modified), true); + }); + + it('קובץ שאינו קיים — זורק', async () => { + // בניגוד ל-`list` ול-`remove`: כאן אין "מצב מבוקש", והקורא חייב + // לדעת שאין קובץ ולא לקבל גודל 0 מזויף. + await assert.rejects(() => fs.stat(at('אין.bin'))); + }); +}); diff --git a/native/test/otzaria_release_client.test.js b/native/test/otzaria_release_client.test.js new file mode 100644 index 0000000..d2f6873 --- /dev/null +++ b/native/test/otzaria_release_client.test.js @@ -0,0 +1,98 @@ +// בירור גרסאות אוצריא שהמראה נבנית עבורן. זו ההחלטה שקובעת מה יורד +// לכונן, ולכן היא נבדקת על תשובות בצורה שהריפו מחזיר בפועל. + +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import {otzariaVersionOf, pickTargetVersions} + from '../web/js/store/otzaria_release_client.js'; + +/** רשומת release מינימלית בצורת התשובה של GitHub. */ +const release = (tag, {prerelease = false, draft = false} = {}) => + ({tag_name: tag, prerelease, draft}); + +describe('otzariaVersionOf', () => { + it('מספר הבילד נחתך', () => { + // ⚠️ זה מה שהריפו מפרסם בפועל, ו-`compatibleWith` באתר הוא `0.9.96` + // בלי הזנב. בלי החיתוך ההשוואה נעשית מול מספר שאינו קיים באתר. + assert.equal(otzariaVersionOf('0.9.96+736'), '0.9.96'); + }); + + it('קידומת v מוסרת', () => { + assert.equal(otzariaVersionOf('v0.9.96'), '0.9.96'); + assert.equal(otzariaVersionOf('v0.2.7'), '0.2.7'); + }); + + it('בילד PR או dev נפסל', () => { + // הם אמורים לשאת גם את הדגל `prerelease`, אבל בריפו יש כאלה בלעדיו — + // ולכן התג עצמו נבדק. + assert.equal(otzariaVersionOf('0.9.53-pr-715-146'), null); + assert.equal(otzariaVersionOf('v0.2.7-dev-110'), null); + }); + + it('תג שאינו גרסה נפסל', () => { + assert.equal(otzariaVersionOf('latest'), null); + assert.equal(otzariaVersionOf(''), null); + assert.equal(otzariaVersionOf(null), null); + assert.equal(otzariaVersionOf(undefined), null); + }); +}); + +describe('pickTargetVersions', () => { + it('האחרונה יציבה — גרסת יעד אחת', () => { + const result = pickTargetVersions([ + release('0.9.96+736'), + release('0.9.95+699'), + ]); + assert.deepEqual(result.versions, ['0.9.96']); + assert.equal(result.latestIsPrerelease, false); + }); + + it('האחרונה טרם יוצבה — גם היציבה האחרונה', () => { + // זו כל הסיבה שיש כאן רשימה ולא ערך אחד: מי שעל הערוץ היציב אינו + // מריץ את ה-prerelease, ובילד שנבחר לפיו לא בהכרח יעלה אצלו. + const result = pickTargetVersions([ + release('0.9.97+740', {prerelease: true}), + release('0.9.96+736'), + release('0.9.95+699'), + ]); + assert.deepEqual(result.versions, ['0.9.97', '0.9.96']); + assert.equal(result.latestIsPrerelease, true); + assert.equal(result.latestStable, '0.9.96'); + }); + + it('draft אינו קיים לציבור', () => { + const result = pickTargetVersions([ + release('0.9.99+800', {draft: true}), + release('0.9.96+736'), + ]); + assert.deepEqual(result.versions, ['0.9.96']); + }); + + it('הגבוהה, לא הראשונה ברשימה', () => { + // סדר הפרסום אינו סדר הגרסאות: תיקון לגרסה ותיקה מתפרסם אחרי החדשה. + const result = pickTargetVersions([ + release('0.9.90+600'), + release('0.9.96+736'), + release('0.9.91+610'), + ]); + assert.deepEqual(result.versions, ['0.9.96']); + }); + + it('רק prerelease-ים — יורדת אחת, ואין יציבה', () => { + const result = pickTargetVersions([ + release('0.9.97+740', {prerelease: true}), + release('0.9.96+736', {prerelease: true}), + ]); + assert.deepEqual(result.versions, ['0.9.97']); + assert.equal(result.latestStable, null); + assert.equal(result.latestIsPrerelease, true); + }); + + it('אין אף תג גרסה — רשימה ריקה', () => { + // המתקשר מתייחס לזה כמו לכשל: יורד הבילד האחרון של כל תוסף. + const result = pickTargetVersions([release('nightly'), null, 7]); + assert.deepEqual(result.versions, []); + assert.equal(result.latest, null); + }); +}); diff --git a/native/test/pack_overlay.test.js b/native/test/pack_overlay.test.js new file mode 100644 index 0000000..554520f --- /dev/null +++ b/native/test/pack_overlay.test.js @@ -0,0 +1,230 @@ +// אריזת המראה לסוף ה-exe — הלוך-חזור מלא: אורזים באמת, ואז פורסים כאן +// לפי הפורמט המתועד ומשווים בייט-לבייט. +// +// ── למה כך ולא בדיקת יחידה ─────────────────────────────────────────────────── +// הצד השני של הפורמט הזה אינו JS אלא C++ (`overlay` ב-native/src), והוא +// אינו זמין ל-`node --test`. לכן הפורש כאן נכתב **מהתיעוד שבראש +// pack_overlay.mjs** ולא מקוד האורז: אם האורז יסטה מהמבנה המתועד, +// הבדיקה תיפול — וזו בדיוק הסטייה שהיתה מייצרת exe שנבנה בהצלחה ואינו +// מצליח לחלץ את עצמו אצל המשתמש. +// +// ⚠️ החותמת נבדקת כאן על **יציבות**: הג'וב השבועי מפרסם רק כשהמראה +// השתנתה, והחותמת היא מה שמזהה חבילה. חותמת שמושפעת מזמן או מסדר סריקה +// הייתה מפיצה ‎96MB זהים בכל שבוע. + +import assert from 'node:assert/strict'; +import {after, before, describe, it} from 'node:test'; +import {execFile} from 'node:child_process'; +import {mkdtemp, mkdir, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const TOOL = join(here, '..', 'tools', 'pack_overlay.mjs'); + +const MAGIC = 'OTZBNDL1'; +const FOOTER_SIZE = 96; + +let root; +const at = (...parts) => join(root, ...parts); + +/** מריץ את הסקריפט ומחזיר את קוד היציאה והפלט, בלי לזרוק. */ +function run(...args) { + return new Promise((resolve) => { + execFile(process.execPath, [TOOL, ...args], {encoding: 'utf8'}, + (error, stdout, stderr) => { + resolve({code: error ? (error.code ?? 1) : 0, stdout, stderr}); + }); + }); +} + +/** + * הפורש — לפי המבנה המתועד בלבד: + * + * [ה-exe][תוכן הקבצים ברצף][אינדקס][footer בן 96 בייט] + * אינדקס: count(u32), ולכל קובץ path_len(u32) size(u64) path(utf8) + * footer: magic(8) indexOffset(u64) indexLength(u64) exeSize(u64) sha256(hex) + */ +function unpack(bundle) { + const footer = bundle.subarray(bundle.length - FOOTER_SIZE); + const parsed = { + magic: footer.toString('latin1', 0, 8), + indexOffset: Number(footer.readBigUInt64LE(8)), + indexLength: Number(footer.readBigUInt64LE(16)), + exeSize: Number(footer.readBigUInt64LE(24)), + digest: footer.toString('latin1', 32, 96), + files: [], + }; + + const index = bundle.subarray(parsed.indexOffset, + parsed.indexOffset + parsed.indexLength); + let cursor = 4; + let dataAt = parsed.exeSize; + const count = index.readUInt32LE(0); + for (let i = 0; i < count; i++) { + const pathLength = index.readUInt32LE(cursor); + const size = Number(index.readBigUInt64LE(cursor + 4)); + const path = index.toString('utf8', cursor + 12, cursor + 12 + pathLength); + cursor += 12 + pathLength; + parsed.files.push({path, size, + bytes: bundle.subarray(dataAt, dataAt + size)}); + dataAt += size; + } + parsed.payloadEnd = dataAt; + return parsed; +} + +/** תיקיית Data קטנה אך מייצגת: תת-תיקייה, שם עברי, ובינארי. */ +async function makeData(dir, {plugin = 'תוכן התוסף הראשון'} = {}) { + await mkdir(join(dir, 'plugins', 'com.example.תוסף'), {recursive: true}); + await mkdir(join(dir, 'images'), {recursive: true}); + await writeFile(join(dir, 'catalog.json'), + JSON.stringify({name: 'קטלוג', plugins: 1})); + await writeFile(join(dir, 'plugins', 'com.example.תוסף', 'plugin-1.0.otzplugin'), + plugin, 'utf8'); + await writeFile(join(dir, 'images', 'icon.webp'), Buffer.alloc(300, 0xab)); + return dir; +} + +/** "exe" — סתם בייטים; הבדיקה כאן היא על הצירוף, לא על PE. */ +const EXE_BYTES = Buffer.alloc(2048, 0x4d); + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'otzaria-overlay-')); + await writeFile(at('app.exe'), EXE_BYTES); +}); + +after(async () => { + await rm(root, {recursive: true, force: true}); +}); + +describe('pack_overlay — הלוך-חזור מול הפורמט', () => { + let bundle; + let parsed; + + before(async () => { + const data = await makeData(at('Data')); + const result = await run(at('app.exe'), data, at('bundle.exe')); + assert.equal(result.code, 0, result.stderr); + bundle = await readFile(at('bundle.exe')); + parsed = unpack(bundle); + }); + + it('ה-exe המקורי נשאר בייט-לבייט בתחילת הקובץ', async () => { + // ⚠️ זו ההבטחה שבגללה נבחר שרשור ולא resource: מה שמופץ הוא **בדיוק** + // הבינארי שנבנה ונחתם, ולא בינארי שנבנה מחדש סביב המטען. שינוי של + // בייט אחד כאן שובר את החתימה. + assert.equal(parsed.exeSize, EXE_BYTES.length); + assert.deepEqual(bundle.subarray(0, parsed.exeSize), EXE_BYTES); + }); + + it('ה-footer הוא 96 בייט עם החתימה OTZBNDL1', () => { + // הגודל קבוע והמיקום בסוף, כדי שהמחלץ ימצא אותו בקפיצה אחת בלי + // לסרוק ‎96MB. `MAGIC` הוא מה שמבדיל בין exe עם מטען לבין exe בלעדיו. + assert.equal(parsed.magic, MAGIC); + assert.equal(parsed.digest.length, 64, 'sha256 בהקסה תופס 64 תווים'); + assert.match(parsed.digest, /^[0-9a-f]{64}$/); + }); + + it('החישוב סוגר בדיוק: exe + מטען + אינדקס + footer', () => { + // אם החשבון הזה אינו מדויק, המחלץ קורא מ-offset שגוי ומקבל זבל — + // בלי שום שגיאה שתסביר למה. + assert.equal(parsed.indexOffset, parsed.payloadEnd); + assert.equal(bundle.length, + parsed.indexOffset + parsed.indexLength + FOOTER_SIZE); + }); + + it('כל קובץ יוצא זהה למקור, כולל השם העברי והבינארי', async () => { + const expected = { + 'catalog.json': await readFile(at('Data', 'catalog.json')), + 'images/icon.webp': await readFile(at('Data', 'images', 'icon.webp')), + 'plugins/com.example.תוסף/plugin-1.0.otzplugin': + await readFile(at('Data', 'plugins', 'com.example.תוסף', + 'plugin-1.0.otzplugin')), + }; + assert.equal(parsed.files.length, 3); + for (const file of parsed.files) { + assert.ok(file.path in expected, `קובץ לא צפוי: ${file.path}`); + assert.equal(file.size, expected[file.path].length, file.path); + assert.deepEqual(file.bytes, expected[file.path], file.path); + } + }); + + it("הנתיבים נשמרים ב-'/' ולא ב-'\\'", () => { + // אותו כלל כמו הקטלוג: מה שנשמר ניטרלי, וההרכבה לנתיב ווינדוס נעשית + // אצל המחלץ. `\` בתוך המחרוזת היה הופך לחלק משם הקובץ. + for (const file of parsed.files) { + assert.ok(!file.path.includes('\\'), file.path); + } + }); + + it('האינדקס ממוין', () => { + // ⚠️ לא קוסמטי: החותמת נגזרת מהסדר הזה, ומיון הוא מה שהופך אותה + // לתלויה בתוכן בלבד ולא בסדר שמערכת הקבצים החזירה. + const paths = parsed.files.map((file) => file.path); + assert.deepEqual(paths, [...paths].sort()); + }); +}); + +describe('pack_overlay — החותמת', () => { + it('שתי אריזות של אותה מראה נותנות אותה חותמת', async () => { + // ⚠️ זו הבדיקה שמונעת פרסום שבועי של ‎96MB זהים. כל תלות בזמן, ב-mtime + // או בסדר הסריקה הייתה מייצרת חותמת אחרת בכל ריצה — והמחלץ אצל + // המשתמש היה מפרש כל חבילה כחדשה ופורש הכול מחדש. + const data = await makeData(at('יציבה')); + await run(at('app.exe'), data, at('a.exe')); + await run(at('app.exe'), data, at('b.exe')); + const a = unpack(await readFile(at('a.exe'))); + const b = unpack(await readFile(at('b.exe'))); + assert.equal(a.digest, b.digest); + // ולא רק החותמת — החבילה כולה זהה. + assert.deepEqual(await readFile(at('a.exe')), await readFile(at('b.exe'))); + }); + + it('שינוי תוכן של קובץ אחד משנה את החותמת', async () => { + // הצד השני של אותו מטבע: חותמת שאינה זזה על שינוי אמיתי הייתה + // משאירה אצל המשתמש את הפרישה הישנה, כי המחלץ משווה חותמות בלבד. + const data = await makeData(at('שונה'), {plugin: 'תוכן התוסף הראשון'}); + await run(at('app.exe'), data, at('c.exe')); + const first = unpack(await readFile(at('c.exe'))).digest; + + await writeFile(join(data, 'plugins', 'com.example.תוסף', + 'plugin-1.0.otzplugin'), + 'תוכן התוסף השני!!', 'utf8'); + await run(at('app.exe'), data, at('d.exe')); + assert.notEqual(unpack(await readFile(at('d.exe'))).digest, first); + }); + + it('שם קובץ שהשתנה משנה את החותמת גם כשהתוכן זהה', async () => { + // הנתיבים נכנסים לחותמת לפני התוכן, כי מראה שבה בילד הוחלף בבילד + // אחר באותו גודל ותוכן-לכאורה היא עדיין מראה אחרת. + const data = await makeData(at('שמות')); + await run(at('app.exe'), data, at('e.exe')); + const first = unpack(await readFile(at('e.exe'))).digest; + + await rm(join(data, 'images', 'icon.webp')); + await writeFile(join(data, 'images', 'icon2.webp'), Buffer.alloc(300, 0xab)); + await run(at('app.exe'), data, at('f.exe')); + assert.notEqual(unpack(await readFile(at('f.exe'))).digest, first); + }); +}); + +describe('pack_overlay — שימוש שגוי', () => { + it('בלי ארגומנטים — יציאה 2', async () => { + // 2 ולא 1: הג'וב מבדיל בין "אין מה לארוז" לבין "הופעל לא נכון". + const result = await run(); + assert.equal(result.code, 2); + assert.match(result.stderr, /usage/); + }); + + it('תיקיית Data ריקה — יציאה 1, ובלי לכתוב חבילה', async () => { + // ⚠️ חבילה בלי מטען היא exe שנראה תקין ואינו מוצא מה לחלץ. עדיף + // להיכשל בג'וב מאשר לפרסם אותה. + const empty = at('ריקה'); + await mkdir(empty, {recursive: true}); + const result = await run(at('app.exe'), empty, at('empty.exe')); + assert.equal(result.code, 1); + assert.match(result.stderr, /ריקה/); + }); +}); diff --git a/native/test/plugin_visibility.test.js b/native/test/plugin_visibility.test.js new file mode 100644 index 0000000..f1c5a57 --- /dev/null +++ b/native/test/plugin_visibility.test.js @@ -0,0 +1,153 @@ +// מה המראה נושאת, ומה מוצג במחשב הזה — שני דברים שונים, וההפרדה ביניהם +// היא כל התיקון: היעד נקבע לפי מה שהריפו של אוצריא פרסם, וגרסת המחשב +// קובעת רק אם יש מה להציע כאן. +// +// התרחיש שהבדיקות האלה שומרות עליו: מחשב מקוון ממלא כונן, והכונן עובר +// למחשב **אחר** שגרסת אוצריא בו שונה. + +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import {PluginCatalog, PluginLocalFile, PluginVersionEntry, StorePlugin} + from '../web/js/store/models.js'; + +const entry = (version, compatibleWith = '', maxAppVersion = null) => + new PluginVersionEntry({ + version, + compatibleWith, + maxAppVersion, + downloadUrl: `https://otzaria.org/${version}.otzplugin`, + }); + +const localFile = (version) => new PluginLocalFile({ + relativePath: `p/plugin-${version}.otzplugin`, + fileName: `plugin-${version}.otzplugin`, + ext: '.otzplugin', + size: 1024, +}); + +/** + * תוסף עם שלושה בילדים: החדש דורש אוצריא 0.9.96, האמצעי מכסה 0.9.90 עד + * 0.9.95, והישן הוא מלפני שנשמרו שדות תאימות. + */ +const plugin = (mirroredVersions) => new StorePlugin({ + id: 'p', + name: 'תוסף', + version: '3.0.0', + versions: [ + entry('3.0.0', '0.9.96'), + entry('2.0.0', '0.9.90', '0.9.95'), + entry('1.0.0'), + ], + manifestId: 'com.example.p', + localFiles: new Map(mirroredVersions.map((v) => [v, localFile(v)])), +}); + +describe('mirrorTargets', () => { + it('בילד לכל גרסת יעד, ממוין יורד', () => { + const targets = plugin([]).mirrorTargets(['0.9.96', '0.9.92']); + assert.deepEqual(targets.map((t) => t.version), ['3.0.0', '2.0.0']); + }); + + it('שתי גרסאות יעד שנפתרות לאותו בילד — אחד', () => { + const targets = plugin([]).mirrorTargets(['0.9.97', '0.9.96']); + assert.deepEqual(targets.map((t) => t.version), ['3.0.0']); + }); + + it('בלי גרסאות יעד — כל הבילדים מועמדים', () => { + // מראה שסונכרנה לפני שהיעד נשמר בקטלוג. אין דרך לדעת עבור מה היא + // נבנתה, ופסילה הייתה מסתירה תוסף שקובץ עובד שלו יושב על הכונן. + const targets = plugin([]).mirrorTargets([]); + assert.equal(targets.length, 3); + }); +}); + +describe('runsOn — האם התוסף מוצג במחשב הזה', () => { + const mirrored = plugin(['3.0.0']); + const targets = ['0.9.96']; + + it('אוצריא ביעד — מוצג', () => { + assert.equal(mirrored.runsOn('0.9.96', targets), true); + assert.equal(mirrored.runsOn('0.9.99', targets), true); + }); + + it('אוצריא ישנה מהיעד — אינו מוצג', () => { + // ⚠️ זה הלב: לתוסף **יש** בילד שירוץ על 0.9.92, אבל הוא אינו על + // הכונן ולא יהיה שם. הצגתו הייתה נותנת כפתור התקנה שדורש אינטרנט + // דווקא במחשב שאין בו. + assert.equal(mirrored.runsOn('0.9.92', targets), false); + }); + + it('גרסה מקומית לא ידועה אינה מסתירה דבר', () => { + // הזיהוי רץ במקביל לטעינה; עד שיסתיים מוצג הכול. + assert.equal(mirrored.runsOn(null, targets), true); + }); + + it('יעד כפול מכסה גם את הישנה וגם את החדשה', () => { + // האחרונה טרם יוצבה, ולכן ירדו שני בילדים. + const both = plugin(['3.0.0', '2.0.0']); + const two = ['0.9.96', '0.9.92']; + assert.equal(both.runsOn('0.9.96', two), true); + assert.equal(both.runsOn('0.9.92', two), true); + assert.equal(both.runsOn('0.9.80', two), false, 'ישנה משתי היעדים'); + }); +}); + +describe('installTarget', () => { + it('הגבוה שירד ותואם למחשב הזה', () => { + const both = plugin(['3.0.0', '2.0.0']); + const two = ['0.9.96', '0.9.92']; + assert.equal(both.installTarget('0.9.96', two).version, '3.0.0'); + assert.equal(both.installTarget('0.9.92', two).version, '2.0.0', + 'הבילד החדש אינו עולה על 0.9.92'); + }); + + it('בילד היסטורי שאינו יעד אינו מוצע', () => { + // בילד 1.0.0 תואם לכל גרסה, אבל הוא לא יירד לעולם — והחזרתו הייתה + // מייצרת "טרם ירד — יש לבצע סנכרון" שאין דרך לפתור. + assert.equal(plugin(['3.0.0']).installTarget('0.9.92', ['0.9.96']), null); + }); + + it('היעד ירד חלקית — מוחזר הבילד שכן על הכונן', () => { + // הורדת הבילד החדש נכשלה והישן נשאר. עדיף בילד ישן שרץ מכלום, וזה + // מה ש-`#syncPluginFiles` דואג שיישאר בקטלוג. + const partial = new StorePlugin({ + id: 'p', + version: '3.0.0', + versions: [entry('3.0.0', '0.9.96'), entry('2.0.0', '0.9.90')], + localFiles: new Map([['2.0.0', localFile('2.0.0')]]), + }); + assert.equal(partial.installTarget('0.9.96', ['0.9.96', '0.9.92']).version, + '2.0.0'); + }); + + it('היעד תואם אך לא ירד — מוחזר בכל זאת', () => { + // כדי שהממשק יאמר "הקובץ לא ירד" ולא "אין תוסף". + assert.equal(plugin([]).installTarget('0.9.96', ['0.9.96']).version, + '3.0.0'); + }); +}); + +describe('גרסאות היעד בקטלוג', () => { + it('נשמרות ונקראות', () => { + const json = new PluginCatalog({targetAppVersions: ['0.9.96', '0.9.92']}) + .toJSON(); + assert.deepEqual(PluginCatalog.fromJson(json).targetAppVersions, + ['0.9.96', '0.9.92']); + }); + + it('קטלוג ישן בלי השדה — רשימה ריקה', () => { + assert.deepEqual(PluginCatalog.fromJson({}).targetAppVersions, []); + }); + + it('ערך פגום אינו מסתיר תוספים', () => { + // רשימה שנפגמה הייתה נקראת כיעד אמיתי, ומסתירה תוספים בלי סיבה. + assert.deepEqual( + PluginCatalog.fromJson({targetAppVersions: '0.9.96'}).targetAppVersions, + []); + assert.deepEqual( + PluginCatalog.fromJson({targetAppVersions: [null, '', '0.9.96']}) + .targetAppVersions, + ['0.9.96']); + }); +}); diff --git a/native/tools/mirror_changed.mjs b/native/tools/mirror_changed.mjs new file mode 100644 index 0000000..487a36f --- /dev/null +++ b/native/tools/mirror_changed.mjs @@ -0,0 +1,143 @@ +// "האם המראה שנבנתה שונה מזו שכבר פורסמה?" — התשובה שקובעת אם הג'וב +// השבועי מפרסם חבילה חדשה או יוצא בשקט. +// +// node native/tools/mirror_changed.mjs +// <רשימת קבצים לפני> <תיקיית plugins> +// node native/tools/mirror_changed.mjs --inventory <תיקיית plugins> +// +// יוצא 0 = יש שינוי (יש לפרסם), 1 = אין שינוי, 2 = שימוש שגוי. +// +// ── למה השוואה ולא גיבוב של ה-API ──────────────────────────────────────────── +// הפיתוי הוא לגבב את `/api/plugins` ולהשוות. זה שגוי כאן: התשובה נושאת +// `downloadCount` ו-`ratingAvg` שזזים כל שעה, וגיבוב שלה היה מכריז על +// שינוי כמה פעמים ביום ומפרסם ‎96MB זהים. +// +// לכן הגלאי הוא **התוצאה**: הג'וב זורע את החבילה הקודמת, מריץ את +// `PluginMirrorSync` (שכבר יודע בדיוק מה השתנה — זה כל תפקידו של +// `#plan`), ומשווה את המראה שיצאה לזו שנזרעה. אין כאן הגדרה שנייה של +// "מה נחשב שינוי" שתוכל להיפרד מזו שבקוד. +// +// ⚠️ מה שזז מעצמו מנוטרל מההשוואה — חותמת הזמן והמונים. ראו +// [VOLATILE_KEYS]: בלי זה הג'וב היה מפרסם בכל ריצה. + +import {readFile, readdir, stat} from 'node:fs/promises'; +// צירוף של הפלטפורמה: השם שנרשם ברשימה נבנה בנפרד ב-`/` (ראו +// `inventory`), וזה כאן משמש למעבר על הדיסק בלבד. +import {join as joinHere} from 'node:path'; + +// `--inventory <תיקיית plugins>` מדפיס את הרשימה בלבד — כך הג'וב מייצר +// את קובץ ה"לפני" מיד אחרי פרישת החבילה הקודמת, באותו קוד שמייצר את +// ה"אחרי". שתי רשימות שנוצרו בשני מימושים אינן ניתנות להשוואה. +if (process.argv[2] === '--inventory') { + const root = process.argv[3]; + if (!root) { + console.error('usage: node mirror_changed.mjs --inventory '); + process.exit(2); + } + process.stdout.write(await inventory(root)); + process.exit(0); +} + +const [beforePath, afterPath, beforeInventoryPath, pluginsDir] = + process.argv.slice(2); +if (!beforePath || !afterPath || !beforeInventoryPath || !pluginsDir) { + console.error('usage: node mirror_changed.mjs ' + + '<רשימת קבצים לפני> <תיקיית plugins>'); + process.exit(2); +} + +/** + * שדות שזזים בלי שהמראה השתנתה, ולכן אינם נספרים בהשוואה: + * + * • `lastSync` — חותמת זמן, זזה בכל ריצה מעצם הגדרתה. + * • המונים — `downloadCount` ודירוגים. הם נשמרים בקטלוג כי הממשק מציג + * אותם, אבל הם משתנים באתר כמה פעמים ביום. בלי הנטרול הזה הג'וב היה + * מפרסם ‎96MB **בכל ריצה**, כי תמיד מישהו הוריד תוסף בינתיים. נמדד: + * סנכרון שדילג על כל 38 התוספים ולא הוריד דבר עדיין הפיק קטלוג שונה, + * ובכל ההבדל היה מונה ההורדות של תוסף אחד. + * + * המחיר מדוד ומקובל: החבילה נושאת מונים מהיום שבו נארזה. הם מתרעננים + * בסנכרון אצל מי שיש לו רשת, ואצל המנותק הם ממילא מספר לידיעה בלבד. + */ +const VOLATILE_KEYS = new Set([ + 'lastSync', 'downloadCount', + 'ratingAvg', 'ratingCount', 'ratingVerifiedCount', 'ratingBreakdown', +]); + +/** מסיר את [VOLATILE_KEYS] בכל עומק — לא רק ברמה העליונה. */ +function stripVolatile(value) { + if (Array.isArray(value)) return value.map(stripVolatile); + if (value === null || typeof value !== 'object') return value; + const out = {}; + for (const [key, inner] of Object.entries(value)) { + if (!VOLATILE_KEYS.has(key)) out[key] = stripVolatile(inner); + } + return out; +} + +/** הקטלוג בלי מה שזז מעצמו, כמחרוזת יציבה להשוואה. */ +async function stableCatalog(path) { + try { + return JSON.stringify(stripVolatile(JSON.parse(await readFile(path, 'utf8')))); + } catch { + // אין קטלוג קודם (פרסום ראשון) או שהוא פגום — שינוי, בהגדרה. + return null; + } +} + +/** + * רשימת הקבצים שבמראה עם גדליהם, ממוינת. + * + * הקטלוג לבדו אינו מספיק: קובץ שנמחק מהדיסק בלי שהקטלוג ישתנה (למשל + * הורדה שנכשלה בריצה קודמת ונרשמה מהרשומה הישנה) הוא הבדל אמיתי בין + * החבילות, והמשתמש המנותק הוא זה שמגלה אותו. + */ +async function inventory(root) { + const out = []; + async function walk(dir, prefix) { + let entries; + try { + entries = await readdir(dir, {withFileTypes: true}); + } catch { + return; + } + for (const entry of entries) { + const full = joinHere(dir, entry.name); + const name = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(full, name); + else out.push(`${name}\t${(await stat(full)).size}`); + } + } + await walk(root, ''); + out.sort(); + return out.join('\n'); +} + +const before = await stableCatalog(beforePath); +const after = await stableCatalog(afterPath); + +if (after === null) { + console.error('הקטלוג שנבנה אינו קריא — אין מה לפרסם'); + process.exit(2); +} + +if (before === null) { + console.log('אין חבילה קודמת להשוות אליה — מפרסמים'); + process.exit(0); +} + +if (before !== after) { + console.log('הקטלוג השתנה — מפרסמים'); + process.exit(0); +} + +// הקטלוג זהה; נשאר לוודא שגם הקבצים עצמם זהים. הרשימה "לפני" נכתבת +// בג'וב מיד אחרי פרישת החבילה הקודמת ולפני הסנכרון. +const previous = await readFile(beforeInventoryPath, 'utf8').catch(() => null); +if (previous !== null && previous !== await inventory(pluginsDir)) { + console.log('קובצי המראה השתנו — מפרסמים'); + process.exit(0); +} + +console.log('המראה זהה לזו שפורסמה — אין מה לפרסם'); +process.exit(1); diff --git a/native/tools/node_host.mjs b/native/tools/node_host.mjs new file mode 100644 index 0000000..c47cb95 --- /dev/null +++ b/native/tools/node_host.mjs @@ -0,0 +1,233 @@ +// מימוש Node של שכבות ה-`fs` וה-`net` שהגשר מספק לצד ה-JS. +// +// ── למה זה קיים ────────────────────────────────────────────────────────────── +// כדי שהאריזה ב-CI תבנה את המראה ב-`PluginMirrorSync` **עצמו** ולא +// בהעתק שלו. כללי הבחירה — איזה בילד יורד לאיזו גרסת אוצריא, מה נחשב +// "לא השתנה", מה נגרע — הם ההחלטות היקרות בפרויקט הזה, ומימוש שני שלהם +// בסקריפט אריזה היה נסדק מול הקוד ביום שבו אחד מהם ישתנה. +// +// לכן מה שנכתב כאן הוא **רק** מה שה-host נותן: קבצים ורשת. אין כאן שום +// החלטה על תוספים. +// +// ⚠️ החוזה נקבע ב-`native/src/fsapi.cpp` וב-`native/src/netapi.cpp`, וכל +// סטייה ממנו מייצרת מראה ש-CI מייצר ושהתוכנה אינה מייצרת. הפרטים +// שנבדקו אחד-אחד מול המקור מסומנים ב-"כמו ב-host" מתחת. + +import {createWriteStream} from 'node:fs'; +import {mkdir, readFile, writeFile, rename, copyFile, stat, readdir, rm, + open} from 'node:fs/promises'; +// ⚠️ `win32` במפורש ולא `path`: כל הנתיבים כאן הם נתיבי ווינדוס עם `\`, +// כי `mirror_store.js` בונה אותם כך והם נשמרים כך בקטלוג. מימוש שהיה +// מצרף ב-`/` על לינוקס היה מייצר שמות קבצים שמכילים `\`. +import {win32 as pathWin32, join as joinHere} from 'node:path'; + +// `dirname` של win32 מזהה גם `/` וגם `\`, ולכן הוא נכון לשני סוגי +// הנתיבים. `join` לעומתו **מייצר** מפריד, ולכן הוא נלקח מהפלטפורמה +// (`joinHere`) בכל מקום שבו התוצאה הולכת למערכת הקבצים ולא לקטלוג — +// ראו את ההערה ב-`list`. +const {dirname} = pathWin32; +import {pipeline} from 'node:stream/promises'; +import {Readable} from 'node:stream'; + +// ── קבצים ──────────────────────────────────────────────────────────────────── + +async function ensureParent(path) { + const parent = dirname(path); + if (parent && parent !== path) await mkdir(parent, {recursive: true}); +} + +export const fs = { + async readText(path) { + return await readFile(path, 'utf8'); + }, + + // כמו ב-host: יוצר את תיקיית האב, וכותב דרך קובץ זמני ושינוי שם — כדי + // שקטלוג לא יישאר חצי-כתוב אם הריצה נקטעה. + async writeText(path, text) { + await ensureParent(path); + const temp = `${path}.tmp`; + await writeFile(temp, text, 'utf8'); + await rename(temp, path); + return true; + }, + + async kind(path) { + try { + return (await stat(path)).isDirectory() ? 'dir' : 'file'; + } catch { + // כמו ב-host: נתיב שאינו קיים אינו שגיאה. + return 'none'; + } + }, + + async stat(path) { + const info = await stat(path); + return {size: info.size, modified: Math.trunc(info.mtimeMs)}; + }, + + // כמו ב-host: תיקייה שאינה קיימת מחזירה רשימה ריקה ולא זורקת. + async list(path) { + let entries; + try { + entries = await readdir(path, {withFileTypes: true}); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return []; + throw error; + } + const out = []; + for (const entry of entries) { + // ⚠️ צירוף של הפלטפורמה ולא של win32: הנתיב הזה **אינו יוצא החוצה** + // (מוחזר `entry.name` בלבד) אלא משמש רק ל-stat כאן. צירוף ב-`\` על + // לינוקס היה מייצר נתיב שאינו קיים, וכל קובץ היה מדווח `size: 0` — + // כלומר מראה שנראית תקינה ושכל קבציה ריקים. + const full = joinHere(path, entry.name); + let size = 0; + let modified = 0; + try { + const info = await stat(full); + size = info.size; + modified = Math.trunc(info.mtimeMs); + } catch { + // קובץ שנעלם בין הסריקה ל-stat — מדווח כמו שהוא, בלי להפיל. + } + out.push({name: entry.name, dir: entry.isDirectory(), size, modified}); + } + return out; + }, + + async mkdirs(path) { + await mkdir(path, {recursive: true}); + return true; + }, + + // כמו ב-host: **קבצים בלבד**, וקובץ שאינו קיים הוא הצלחה — המצב + // המבוקש הושג. + async remove(path) { + await rm(path, {force: true}); + return true; + }, + + async copy(from, to) { + await ensureParent(to); + await copyFile(from, to); + return true; + }, + + async rename(from, to) { + await ensureParent(to); + await rename(from, to); + return true; + }, + + // קריאת קטע מתוך קובץ — זה מה ש-`manifest_reader.js` נשען עליו כדי + // לקרוא את ה-manifest מתוך ה-ZIP בלי לפרוש אותו. + async readBase64(path, offset, length) { + const handle = await open(path, 'r'); + try { + const buffer = Buffer.alloc(Number(length)); + const {bytesRead} = + await handle.read(buffer, 0, Number(length), Number(offset)); + return buffer.subarray(0, bytesRead).toString('base64'); + } finally { + await handle.close(); + } + }, + + async fileExists(path) { + return (await this.kind(path)) === 'file'; + }, + + async dirExists(path) { + return (await this.kind(path)) === 'dir'; + }, +}; + +// ── רשת ────────────────────────────────────────────────────────────────────── + +// ⚠️ GitHub מחזיר 403 לכל בקשה בלי User-Agent. ב-host זה נקבע ברמת +// הסשן (`kUserAgent` ב-netapi.cpp), ולכן שום קורא אינו מוסר אותו — ומכאן +// שהוא חייב להיקבע גם כאן, ובאותו מקום אחד. +const USER_AGENT = 'otzaria-plugin-store-bundler'; + +/** + * שעון עצר שמתאפס בכל בייט שמגיע. `timeoutMs` הוא הגבול לכל ההורדה, + * ו-`stallMs` הוא הגבול לשקט **בתוכה** — חיבור שנתקע באמצע קובץ של + * ‎45MB לא ייתפס בלי זה עד תום הגבול הכולל. + */ +function abortOnStall(controller, stallMs) { + if (!stallMs) return {touch() {}, stop() {}}; + let timer = null; + const arm = () => { + timer = setTimeout(() => controller.abort(new Error('ההורדה נתקעה')), + stallMs); + }; + arm(); + return { + touch() { + clearTimeout(timer); + arm(); + }, + stop() { + clearTimeout(timer); + }, + }; +} + +export const net = { + async get(url, timeoutMs = 20000) { + const response = await fetch(url, { + headers: {'User-Agent': USER_AGENT}, + signal: AbortSignal.timeout(Number(timeoutMs)), + }); + return { + status: response.status, + contentType: response.headers.get('content-type') ?? '', + body: await response.text(), + }; + }, + + /** + * מוריד לקובץ ומחזיר את הכותרות **גולמיות**. ההחלטה על שם וסיומת + * אינה כאן אלא ב-`net_headers.js`, בדיוק כמו מול ה-host. + */ + async download(url, destPath, timeoutMs = 20000, stallMs = 0) { + const controller = new AbortController(); + const overall = setTimeout( + () => controller.abort(new Error('פג הזמן הקצוב להורדה')), + Number(timeoutMs)); + try { + const response = await fetch(url, { + headers: {'User-Agent': USER_AGENT}, + signal: controller.signal, + }); + const headers = { + status: response.status, + contentType: response.headers.get('content-type') ?? '', + contentDisposition: response.headers.get('content-disposition') ?? '', + }; + + // כמו ב-host: סטטוס שאינו 200 מוחזר בלי לכתוב קובץ — המתקשר הוא + // שמחליט מה זה אומר. + if (response.status !== 200 || response.body === null) { + return {...headers, size: 0}; + } + + await ensureParent(destPath); + const stall = abortOnStall(controller, Number(stallMs)); + let size = 0; + const source = Readable.fromWeb(response.body); + source.on('data', (chunk) => { + size += chunk.length; + stall.touch(); + }); + try { + await pipeline(source, createWriteStream(destPath)); + } finally { + stall.stop(); + } + return {...headers, size}; + } finally { + clearTimeout(overall); + } + }, +}; diff --git a/native/tools/pack_mirror.mjs b/native/tools/pack_mirror.mjs new file mode 100644 index 0000000..abaee21 --- /dev/null +++ b/native/tools/pack_mirror.mjs @@ -0,0 +1,93 @@ +// בונה מראה מלאה (`Data\`) בשביל החבילה המלאה — בהרצת `PluginMirrorSync` +// **עצמו** מול שכבת ה-Node שב-`node_host.mjs`. +// +// node native/tools/pack_mirror.mjs <תיקיית Data> +// +// ── מה זה כן ומה זה לא ─────────────────────────────────────────────────────── +// הסקריפט הזה אינו יודע דבר על תוספים. הוא מחווט את אותם ארבעה חלקים +// שהתוכנה מחווטת (`main.js` → `PluginsManager`), מריץ `sync()`, ומדפיס. +// כל החלטה — איזה בילד לאיזו גרסת אוצריא, מה נחשב "לא השתנה", מה נגרע — +// נשארת בקוד שהתוכנה מריצה, ונבדקת ב-`node --test`. +// +// ⚠️ **התיקייה אינה נמחקת כאן.** הג'וב זורע אותה מהחבילה הקודמת לפני +// הריצה, ואז `#plan` מוריד רק את הדלתא: עדכון של תוסף אחד = קובץ אחד +// מ-otzaria.org ולא 35. זו גם הסיבה שהמסלול שנבדק ב-CI הוא המסלול +// האינקרמנטלי שכונן של משתמש עובר בעדכון, ולא "מראה ריקה" שאיש אינו חי בו. + +import {win32} from 'node:path'; + +import {fs, net} from './node_host.mjs'; +import {OtzariaReleaseClient} from '../web/js/store/otzaria_release_client.js'; +import {PluginMirrorStore} from '../web/js/store/mirror_store.js'; +import {PluginMirrorSync, SyncPhase} from '../web/js/store/mirror_sync.js'; +import {PluginStoreClient} from '../web/js/store/store_client.js'; + +const {join} = win32; + +const dataDir = process.argv[2]; +if (!dataDir) { + console.error('usage: node pack_mirror.mjs '); + process.exit(2); +} + +// ── גרסאות היעד ────────────────────────────────────────────────────────────── +// בתוכנה, כשל בבירור הגרסאות נבלע ומשמעותו "אין מול מה לסנן" — המשתמש +// מקבל את הבילד החי וממשיך. **כאן זה כשל מוחלט**: חבילה שנבנתה בלי +// היעד נושאת בילדים שלא נבחרו לפי שום כלל, ומופצת ככה לכונן של מישהו. +// עדיף לא לפרסם חבילה מאשר לפרסם כזאת. +const releases = new OtzariaReleaseClient({net}); +const target = await releases.fetchTargetVersions(); +if (target.versions.length === 0) { + console.error('אין אף release של אוצריא עם תג גרסה — אין יעד לבנות עבורו'); + process.exit(1); +} +console.log(`גרסאות היעד: ${target.versions.join(', ')}` + + (target.latestIsPrerelease ? ' (האחרונה היא prerelease)' : '')); + +// ── החיווט, כמו ב-`PluginsManager` ─────────────────────────────────────────── +// אותם שלושה נתיבים ש-`paths.h` מגדיר, ובאותה צורה — `\` של ווינדוס, +// כי כך `mirror_store.js` מרכיב את השאר וכך הם נשמרים בקטלוג. +const store = new PluginMirrorStore({ + dataDir, + pluginsDir: join(dataDir, 'plugins'), + catalogPath: join(dataDir, 'catalog.json'), +}, fs); + +const sync = new PluginMirrorSync({ + client: new PluginStoreClient({net, fs}), + store, + // לקריאת ה-manifest מתוך ה-ZIP שירד, בלי לפרוש אותו. + io: { + stat: (path) => fs.stat(path), + readBase64: (path, offset, length) => fs.readBase64(path, offset, length), + }, +}); + +// ── ההרצה ──────────────────────────────────────────────────────────────────── +const warnings = []; +const result = await sync.sync({ + appVersions: target.versions, + onProgress: ({phase, message}) => { + if (phase === SyncPhase.warning) warnings.push(message); + console.log(` ${message}`); + }, +}); + +console.log(''); +console.log(`ירדו: ${result.fetched} | דולגו (כבר מעודכנים): ${result.skipped}`); +if (result.incompatible.length > 0) { + // ⚠️ זה **חייב** להופיע ביומן: "למה התוסף הזה לא בחבילה" הוא השאלה + // שתישאל, ואין דרך אחרת לענות עליה בדיעבד. + console.log(`אין בילד תואם ליעד (${result.incompatible.length}): ` + + result.incompatible.join(' | ')); +} +for (const warning of warnings) console.log(`אזהרה: ${warning}`); + +// כשל בנכס בודד אינו מפיל את הסנכרון בתוכנה — המשתמש יסנכרן שוב. חבילה +// היא הזדמנות אחת, והמשתמש שלה מנותק: קובץ חסר בה אינו ניתן לתיקון אצלו. +if (result.hasFailures) { + console.error(`כשלו: ${result.failed.join(' | ')}`); + process.exit(1); +} + +console.log(`המראה מוכנה ב-${dataDir}`); diff --git a/native/tools/pack_overlay.mjs b/native/tools/pack_overlay.mjs new file mode 100644 index 0000000..06e5907 --- /dev/null +++ b/native/tools/pack_overlay.mjs @@ -0,0 +1,125 @@ +// מצרף מראה שלמה (`Data\`) לסוף ה-exe שכבר נבנה ופורסם, ומייצר את +// החבילה המלאה. +// +// node native/tools/pack_overlay.mjs <תיקיית Data> +// +// ── למה שרשור ולא resource ─────────────────────────────────────────────────── +// המטען הוא ~‎96MB, וכל מה שהוא צריך הוא לזרום לדיסק פעם אחת. RCDATA היה +// דורש בנייה מחדש של ה-exe סביבו — כלומר בינארי שאינו זה שנבדק ופורסם — +// ובנוסף `rc.exe`/`link.exe` עם גוש כזה הם עסק כבד. שרשור לסוף ה-PE +// משאיר את הבינארי המקורי **בייט-לבייט** ומאפשר קריאה ב-offset בלי +// למפות דבר לזיכרון. +// +// ⚠️ **אין דחיסה, במכוון.** ה-`.otzplugin` הם ZIP וה-image הם webp/png: +// LZMS עליהם מחזיר אחוזים בודדים תמורת פרישה של ‎96MB לזיכרון. הקבצים +// נשמרים גולמיים, וכך החילוץ הוא העתקה בזרימה בלי הקצאה גדולה אחת. +// +// ── המבנה ──────────────────────────────────────────────────────────────────── +// [ ה-exe המקורי, ללא שינוי ] +// [ תוכן הקבצים, ברצף, בסדר האינדקס ] +// [ אינדקס: count(u32), ואז לכל קובץ path_len(u32) size(u64) path(utf8) ] +// [ footer: 96 בייט קבועים בסוף הקובץ ] +// +// ה-footer בסוף ובגודל קבוע כדי שהקורא יוכל למצוא אותו בקפיצה אחת, בלי +// לסרוק ובלי לדעת מראש כמה גדול המטען. + +import {createReadStream, createWriteStream} from 'node:fs'; +import {readdir, stat, readFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {pipeline} from 'node:stream/promises'; +// צירוף של הפלטפורמה: הנתיב שנשמר בחבילה נבנה בנפרד ב-`/` (ראו +// `collect`), וזה כאן משמש לקריאה מהדיסק בלבד. +import {join} from 'node:path'; + +// חייב להתאים ל-`kOverlayMagic` ב-native/src/overlay.h. +const MAGIC = 'OTZBNDL1'; +const FOOTER_SIZE = 96; + +const [exePath, dataDir, outPath] = process.argv.slice(2); +if (!exePath || !dataDir || !outPath) { + console.error('usage: node pack_overlay.mjs '); + process.exit(2); +} + +/** + * כל הקבצים תחת [root], בנתיבים יחסיים בסגנון POSIX וממוינים. + * + * **ממוין** כדי שהחותמת תהיה תלויה בתוכן בלבד ולא בסדר שמערכת הקבצים + * החזירה — אחרת אותה מראה בדיוק הייתה מקבלת חותמת אחרת בכל ריצה, + * והג'וב היה מפרסם חבילה זהה. + * + * `/` ולא `\` — אותו כלל כמו הנתיבים שבקטלוג: מה שנשמר הוא ניטרלי, + * וההרכבה לנתיב ווינדוס נעשית אצל הקורא. + */ +async function collect(root, prefix = '') { + const out = []; + for (const entry of await readdir(root, {withFileTypes: true})) { + const full = join(root, entry.name); + const name = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...await collect(full, name)); + else out.push({path: name, full, size: (await stat(full)).size}); + } + return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +const files = await collect(dataDir); +if (files.length === 0) { + console.error(`${dataDir} ריקה — אין מה לארוז`); + process.exit(1); +} + +// ── האינדקס ────────────────────────────────────────────────────────────────── +const parts = []; +const count = Buffer.alloc(4); +count.writeUInt32LE(files.length); +parts.push(count); +for (const file of files) { + const path = Buffer.from(file.path, 'utf8'); + const header = Buffer.alloc(12); + header.writeUInt32LE(path.length, 0); + header.writeBigUInt64LE(BigInt(file.size), 4); + parts.push(header, path); +} +const index = Buffer.concat(parts); + +// ── הכתיבה ─────────────────────────────────────────────────────────────────── +const exeSize = (await stat(exePath)).size; +const out = createWriteStream(outPath); + +// החותמת מזהה את החבילה: המחלץ משווה אותה למה שרשום ב-`Data\`, ולפי זה +// יודע אם התיקייה שלידו היא של החבילה הזאת או של קודמת. היא נגזרת +// מהנתיבים ומהתוכן — לא מהזמן — כדי ששתי ריצות על אותה מראה ייתנו אותה +// חבילה ולא יגררו פרסום מיותר. +const digest = createHash('sha256'); +for (const file of files) digest.update(file.path, 'utf8'); + +await pipeline(createReadStream(exePath), out, {end: false}); + +let written = 0; +for (const file of files) { + const bytes = await readFile(file.full); + digest.update(bytes); + if (!out.write(bytes)) await new Promise((r) => out.once('drain', r)); + written += bytes.length; +} + +const indexOffset = exeSize + written; +out.write(index); + +const footer = Buffer.alloc(FOOTER_SIZE); +footer.write(MAGIC, 0, 'latin1'); +footer.writeBigUInt64LE(BigInt(indexOffset), 8); +footer.writeBigUInt64LE(BigInt(index.length), 16); +footer.writeBigUInt64LE(BigInt(exeSize), 24); +footer.write(digest.digest('hex'), 32, 'latin1'); +out.write(footer); + +await new Promise((resolve, reject) => { + out.on('error', reject); + out.end(resolve); +}); + +const mb = (bytes) => `${(bytes / 1048576).toFixed(1)}MB`; +console.log(`${outPath}`); +console.log(` exe: ${mb(exeSize)} | מטען: ${mb(written)} | קבצים: ${files.length}`); +console.log(` סה"כ: ${mb(exeSize + written + index.length + FOOTER_SIZE)}`); diff --git a/native/tools/unpack_overlay.mjs b/native/tools/unpack_overlay.mjs new file mode 100644 index 0000000..e782dfd --- /dev/null +++ b/native/tools/unpack_overlay.mjs @@ -0,0 +1,97 @@ +// פורס overlay מתוך חבילה מלאה חזרה לתיקיית `Data`. +// +// node native/tools/unpack_overlay.mjs <תיקיית יעד> +// +// ── למה זה קיים ────────────────────────────────────────────────────────────── +// לא בשביל המשתמש — אצלו החילוץ נעשה ב-C++ (`native/src/overlay.cpp`). +// זה בשביל **הג'וב**: הוא זורע את המראה מהחבילה הקודמת לפני שהוא מריץ +// את הסנכרון, וכך `#plan` מוריד מ-otzaria.org רק את הדלתא. עדכון של +// תוסף אחד עולה קובץ אחד ולא 96MB. +// +// יוצא 1 כשאין overlay — הג'וב מתייחס לזה כ"אין חבילה קודמת", וזה מצב +// תקין לחלוטין (הפרסום הראשון). + +import {open, mkdir, writeFile} from 'node:fs/promises'; +import {win32} from 'node:path'; + +const MAGIC = 'OTZBNDL1'; +const FOOTER_SIZE = 96; + +// `--slim` מוציא את ה-exe שבתוך החבילה במקום את המראה. +// +// ⚠️ זה מה שמאפשר לג'וב לזהות **release חדש של החנות**. בלעדיו נוצר פער +// שקט: כשמתפרסם exe חדש המראה אינה משתנה, הגלאי אומר "אין מה לפרסם", +// והחבילה נשארת עם בינארי ישן לנצח. השוואת הבייטים כאן היא התשובה +// המדויקת — בלי לרשום בשום מקום מאיזה תג נבנתה החבילה. +const slimOnly = process.argv[2] === '--slim'; +const [exePath, outDir] = process.argv.slice(slimOnly ? 3 : 2); +if (!exePath || !outDir) { + console.error('usage: node unpack_overlay.mjs [--slim] <יעד>'); + process.exit(2); +} + +const handle = await open(exePath, 'r'); +try { + const {size} = await handle.stat(); + if (size < FOOTER_SIZE) { + console.error('הקובץ קטן מ-footer — אין overlay'); + process.exit(1); + } + + const footer = Buffer.alloc(FOOTER_SIZE); + await handle.read(footer, 0, FOOTER_SIZE, size - FOOTER_SIZE); + if (footer.subarray(0, 8).toString('latin1') !== MAGIC) { + console.error('אין overlay בקובץ הזה'); + process.exit(1); + } + + const indexOffset = Number(footer.readBigUInt64LE(8)); + const indexSize = Number(footer.readBigUInt64LE(16)); + const dataOffset = Number(footer.readBigUInt64LE(24)); + const stamp = footer.subarray(32, 96).toString('latin1'); + + if (slimOnly) { + // `outDir` הוא קובץ במצב הזה. ה-exe הרזה הוא בדיוק `dataOffset` + // הבייטים הראשונים — ראו `pack_overlay.mjs`. + const slim = Buffer.alloc(dataOffset); + await handle.read(slim, 0, dataOffset, 0); + await writeFile(outDir, slim); + console.log(`ה-exe שבתוך החבילה נכתב אל ${outDir}`); + process.exit(0); + } + + const index = Buffer.alloc(indexSize); + await handle.read(index, 0, indexSize, indexOffset); + + let cursor = 0; + const count = index.readUInt32LE(cursor); + cursor += 4; + + const entries = []; + for (let i = 0; i < count; i++) { + const pathLength = index.readUInt32LE(cursor); + const fileSize = Number(index.readBigUInt64LE(cursor + 4)); + cursor += 12; + entries.push({ + path: index.subarray(cursor, cursor + pathLength).toString('utf8'), + size: fileSize, + }); + cursor += pathLength; + } + + // הקבצים יושבים ברצף מיד אחרי ה-exe, בסדר האינדקס. + let at = dataOffset; + for (const entry of entries) { + const full = win32.join(outDir, ...entry.path.split('/')); + await mkdir(win32.dirname(full), {recursive: true}); + const bytes = Buffer.alloc(entry.size); + if (entry.size > 0) await handle.read(bytes, 0, entry.size, at); + await writeFile(full, bytes); + at += entry.size; + } + + console.log(`נפרסו ${entries.length} קבצים אל ${outDir}`); + console.log(`חותמת: ${stamp}`); +} finally { + await handle.close(); +} diff --git a/native/web/css/app.css b/native/web/css/app.css index 156f7a2..a64cb76 100644 --- a/native/web/css/app.css +++ b/native/web/css/app.css @@ -890,13 +890,26 @@ } .error-row, -.progress-row { +.progress-row, +.hidden-note { display: flex; align-items: center; gap: var(--space-md); padding: var(--space-md); } +/* שורת "תוספים שאינם מוצגים" — מצב תקין, ולכן בצבע המשני ולא באזהרה. */ +.hidden-note { + color: var(--on-surface-variant); +} + +.hidden-note__text { + flex: 1 1 auto; + min-width: 0; + font-size: var(--font-md); + overflow-wrap: anywhere; +} + .error-row { color: var(--error); } diff --git a/native/web/js/controller.js b/native/web/js/controller.js index 9bd4986..828a278 100644 --- a/native/web/js/controller.js +++ b/native/web/js/controller.js @@ -40,15 +40,19 @@ const INSTALL_WATCH_INTERVAL_MS = 1000; const INSTALL_WATCH_TIMEOUT_MS = 5 * 60 * 1000; export class StoreController { - constructor({manager, probe, log = () => {}}) { + constructor({manager, probe, releaseClient, log = () => {}}) { this.manager = manager; this.probe = probe; + /** + * מברר מהן גרסאות אוצריא שהמראה נבנית עבורן. נדרש **רק** למסלולים + * המקוונים (סנכרון והצצה); הטעינה וההתקנה אינן נוגעות בו. + */ + this.releaseClient = releaseClient; this.log = log; this.status = Status.idle; this.errorMessage = null; - this.plugins = []; /** קטגוריות החנות בסדר שנקבע באתר. ריק במראה ישנה. */ this.categories = []; this.home = PluginStoreHome.empty; @@ -57,11 +61,18 @@ export class StoreController { this.pluginsDir = null; /** - * גרסת אוצריא שבמחשב **הזה**, ולפיה נבחר איזה בילד יוצג ויותקן. - * `null` = לא ידוע, ואז אין מול מה לסנן והבילד החי הוא שנבחר. + * גרסת אוצריא שבמחשב **הזה**. היא אינה קובעת מה יורד — היא קובעת + * מה **מוצג**: תוסף שאין במראה בילד שירוץ עליה אינו מופיע בחנות. + * `null` = לא ידוע (הזיהוי טרם הסתיים), ואז אין מול מה לסנן. */ this.appVersion = null; + /** + * גרסאות אוצריא שהמראה נבנתה עבורן, כפי שנשמרו בקטלוג. ריק במראה + * שסונכרנה לפני שהשדה נוסף — ראו `StorePlugin.mirrorTargets`. + */ + this.targetAppVersions = []; + // ── ניווט ────────────────────────────────────────────────────────────── /** * החנות נפתחת בדף הבית האצור, בדיוק כמו באתר; מראה בלי קטגוריות @@ -95,6 +106,8 @@ export class StoreController { this.syncWarnings = []; this.syncCancelled = false; + this.#catalogPlugins = []; + this.#targetVersions = null; this.#installed = new Map(); this.#listeners = new Set(); this.#pendingInstalls = new Map(); @@ -104,6 +117,8 @@ export class StoreController { this.#invalidateDerived(); } + #catalogPlugins; + #targetVersions; #installed; #listeners; #pendingInstalls; @@ -175,10 +190,13 @@ export class StoreController { this.#notify(); try { - // לפני הקטלוג: הגרסה היא שקובעת איזה בילד כל תוסף מציג. + // לפני הקטלוג: הגרסה היא שקובעת אילו תוספים בכלל מוצגים. הזיהוי + // רץ במקביל ועשוי לא להסתיים עדיין — `null` אינו מסתיר דבר, ומי + // שיסיים קורא ל-`refreshInstalled` שמסנן מחדש. this.appVersion = this.probe.version; const snapshot = await this.manager.load(); - this.plugins = snapshot.catalog.plugins; + this.#catalogPlugins = snapshot.catalog.plugins; + this.targetAppVersions = snapshot.catalog.targetAppVersions; this.categories = snapshot.catalog.categories; this.home = snapshot.catalog.home; this.lastSync = snapshot.catalog.lastSync; @@ -389,17 +407,43 @@ export class StoreController { } /** - * הגרסאות שההורדה וההצצה מסננות לפיהן. + * הגרסאות שההורדה וההצצה מסננות לפיהן — **מה שהריפו של אוצריא פרסם**, + * ולא מה שמותקן במחשב הזה. + * + * ⚠️ **למה לא הגרסה המקומית:** המראה נבנית במחשב מקוון ונצרכת במחשב + * אחר. סינון לפי המחשב המסנכרן פירושו כונן שנושא בילדים שאינם מתאימים + * למחשב היעד, ושם — בלי אינטרנט — אין דרך לתקן את זה. ראו + * `otzaria_release_client.js`. * - * **ממתין תחילה לזיהוי**: רשימה ריקה מפני שהוא עוד לא הספיק אינה "אין - * מול מה לסנן" אלא תשובה שגויה — ההצצה הייתה שואלת על הבילד החי, - * מדווחת "חסר" על מה שאינו תואם, וההורדה שאחריה לא הייתה מביאה דבר. - * כך הנדנוד חוזר אחרי כל הורדה. + * נשמר לכל אורך ההרצה, אבל **רק בהצלחה**: תשובה ריקה מפני שאין רשת + * אינה עובדה על הריפו, ואסור שתיתקע בזיכרון עד סוף ההרצה. */ async #appVersions() { - await this.probe.ensureDetected(); - const version = this.probe.version; - return version ? [version] : []; + if (this.#targetVersions !== null) return this.#targetVersions; + + let result; + try { + result = await this.releaseClient.fetchTargetVersions(); + } catch (error) { + // לא שגיאת הרצה: בלי היעד יורד הבילד האחרון של כל תוסף, וזו בדיוק + // ההתנהגות שהייתה לפני שהתאימות נכנסה. + this.log('בירור גרסת אוצריא האחרונה לא הצליח ' + + `(${error?.message ?? error}) — יורד הבילד האחרון של כל תוסף`); + return []; + } + + if (result.versions.length === 0) { + this.log('לא נמצאה אף גרסה של אוצריא בריפו — ' + + 'יורד הבילד האחרון של כל תוסף'); + return []; + } + + this.log(result.latestIsPrerelease + ? `גרסאות היעד: ${result.latest} (טרם הוגדרה כיציבה) ` + + `ו-${result.latestStable}` + : `גרסת היעד: ${result.latest}`); + this.#targetVersions = result.versions; + return result.versions; } // ── פעולות על תוסף בודד ────────────────────────────────────────────────── @@ -409,7 +453,8 @@ export class StoreController { } statusOf(plugin) { - return plugin.statusAgainst(this.#installed, this.appVersion); + return plugin.statusAgainst(this.#installed, this.appVersion, + this.targetAppVersions); } /** הגרסה המותקנת של התוסף, או null אם אינו מותקן. */ @@ -420,11 +465,11 @@ export class StoreController { } /** - * הבילד שיותקן במחשב הזה — לא בהכרח האחרון שפורסם. `null` = אין בילד - * שתואם לגרסת אוצריא שכאן. + * הבילד שיותקן במחשב הזה — הגבוה מבין אלה שהמראה נושאת ושתואם לגרסת + * אוצריא שכאן. `null` = אין כזה, וממילא התוסף אינו מוצג. */ targetOf(plugin) { - return plugin.installTarget(this.appVersion); + return plugin.installTarget(this.appVersion, this.targetAppVersions); } /** מספר הגרסה להצגה: של הבילד שיותקן, ובחוסר — של החי בקטלוג. */ @@ -438,7 +483,8 @@ export class StoreController { } suggestedFileName(plugin) { - return this.manager.suggestedFileName(plugin, this.appVersion); + return this.manager.suggestedFileName(plugin, this.appVersion, + this.targetAppVersions); } /** נתיב מוחלט לנכס שנשמר בקטלוג כנתיב יחסי. */ @@ -447,7 +493,8 @@ export class StoreController { } saveCopy(plugin, destPath) { - return this.manager.saveCopy(plugin, destPath, this.appVersion); + return this.manager.saveCopy(plugin, destPath, this.appVersion, + this.targetAppVersions); } /** @@ -455,7 +502,8 @@ export class StoreController { * נגמרת בחלון של אוצריא, ולכן מתחילים מיד להמתין לה על הדיסק. */ async install(plugin) { - const result = await this.manager.install(plugin, this.appVersion); + const result = await this.manager.install(plugin, this.appVersion, + this.targetAppVersions); if (result.success) { this.watchForInstall(plugin); } else { @@ -572,6 +620,30 @@ export class StoreController { return this.#derived[key]; } + /** + * התוספים שיש מה להציע להם במחשב הזה — **מקור האמת לכל המסכים**. + * + * תוסף שאין במראה בילד שירוץ על גרסת אוצריא שכאן אינו מוצג כלל: לא + * בדף הבית, לא בקטגוריה, לא בחיפוש ולא ברשימת התגיות. הצגתו הייתה + * מייצרת כפתור התקנה שאין לו קובץ, ובמחשב לא-מקוון גם אין דרך להשיגו. + * + * גרסה מקומית שאינה ידועה (הזיהוי טרם הסתיים, או שאוצריא לא נמצאה) + * אינה מסתירה דבר — ראו `isCompatibleWithApp`. + */ + get plugins() { + return this.#memo('plugins', () => this.#catalogPlugins.filter( + (plugin) => plugin.runsOn(this.appVersion, this.targetAppVersions))); + } + + /** + * כמה תוספים הוסתרו בגלל תאימות. מוצג כשורת הסבר אחת בתחתית הרשימה: + * חנות שחסרים בה תוספים בלי שנאמר למה אינה ניתנת לאבחון מרחוק, וזו + * בדיוק השאלה שמגיעה מהמשתמשים. + */ + get hiddenCount() { + return this.#catalogPlugins.length - this.plugins.length; + } + /** * האם התוסף עובר את מתג "רק מה שלא מותקן". המתג הוא תוספת של הלאנצ'ר * ולכן חל על **כל** המסכים, גם על האצירה. diff --git a/native/web/js/main.js b/native/web/js/main.js index 872184c..fe4fdbc 100644 --- a/native/web/js/main.js +++ b/native/web/js/main.js @@ -19,6 +19,8 @@ import {PluginsManager} from './store/plugins_manager.js'; import {OtzariaInstallProbe} from './store/otzaria_probe.js'; import {StoreReleaseClient, isStoreVersionNewer} from './store/store_release_client.js'; +import {OtzariaReleaseClient} from './store/otzaria_release_client.js'; +import {formatBytes} from './util/bytes.js'; import {h, icon, replace} from './ui/dom.js'; import {installIconSprite} from './ui/icons.js'; import {actionButton, iconButton} from './ui/components.js'; @@ -120,7 +122,11 @@ async function main() { }, }); - const controller = new StoreController({manager, probe, log}); + // מברר מהן גרסאות אוצריא שהמראה נבנית עבורן. נקרא **רק** כשהמשתמש + // מסנכרן או בודק עדכונים, ולכן העלייה עצמה נשארת בלי רשת. + const releaseClient = new OtzariaReleaseClient({net}); + + const controller = new StoreController({manager, probe, releaseClient, log}); // רינדור מלא בכל שינוי מצב. המסך הוא כמה עשרות אלמנטים, והבקר הוא // מקור האמת היחיד — ראו ההערה בראש `ui/screens.js`. @@ -166,6 +172,9 @@ async function main() { // ── בדיקת גרסה חדשה של החנות ──────────────────────────────────────────── void checkForNewVersion(banners, info.version, log); + // ── קובץ ההתקנה של החבילה, אם נשאר כזה ────────────────────────────────── + void offerInstallerCleanup(banners, info.installerPath, log); + // רינדור מחדש בשינוי גודל: מספר העמודות וסרגל הצד תלויים ברוחב. let resizeTimer = null; window.addEventListener('resize', () => { @@ -239,6 +248,58 @@ async function checkForNewVersion(banners, currentVersion, log) { banners.append(banner); } +/** + * מציע למחוק את קובץ ההתקנה של החבילה המלאה, אחרי שהיא כבר פרסה את + * עצמה והעבירה את השרביט ל-exe הרזה שרץ עכשיו. + * + * ⚠️ **מציע ולא מוחק.** הפיתוי הוא למחוק לבד — הקובץ הוא ~‎96MB שכל + * תוכנו כבר יושב בתיקייה שלידו, וזה בדיוק מה שהמשתמש ביקש להימנע ממנו. + * אבל אותה חבילה עשויה להיות מיועדת גם למחשב שני, ומחיקה שקטה של הורדה + * בגודל כזה הייתה מאלצת להוריד אותה מחדש — במחשב שאין בו אינטרנט. + * + * ה-host הוא שמכריע אם יש בכלל מה למחוק: `installerPath` ריק כשהקובץ + * נעלם, כשהוא אינו קובץ, וכשהוא קובץ ההרצה שרץ עכשיו — ראו + * `InstallerPath()` ב-bridge.cpp. + */ +async function offerInstallerCleanup(banners, installerPath, log) { + if (!installerPath) return; + + let size = ''; + try { + size = formatBytes((await fs.stat(installerPath)).size); + } catch (error) { + // הקובץ נעלם בין הבדיקה של ה-host לכאן — אין מה להציע. + log(`קובץ ההתקנה אינו נגיש: ${error?.message ?? error}`); + return; + } + + const t = S.bundleInstaller; + const banner = h('div.update-banner', + icon('checkmark-circle', 20), + h('div.update-banner__text', {text: t.bannerTitle(size)}), + actionButton({ + text: t.bannerButton, + variant: 'neutral', + onPressed: async () => { + try { + await fs.remove(installerPath); + } catch (error) { + showError(t.deleteFailed(error?.message ?? error)); + return; + } + log(`קובץ ההתקנה נמחק: ${installerPath}`); + banner.remove(); + showSnack(t.deleted, 'success'); + }, + }), + iconButton({ + iconName: 'dismiss', + tooltip: t.bannerDismissTooltip, + onPressed: () => banner.remove(), + })); + banners.append(banner); +} + main().catch((error) => { // כשל בעלייה עצמה — אין ממשק להציג בו, ולכן הוא נכתב אל הדף גולמי. document.body.textContent = diff --git a/native/web/js/store/compatibility.js b/native/web/js/store/compatibility.js index f786533..b726c79 100644 --- a/native/web/js/store/compatibility.js +++ b/native/web/js/store/compatibility.js @@ -41,10 +41,13 @@ export function resolveCompatibleVersion(entries, appVersion) { /** * הבילדים שצריכים לרדת למראה עבור [appVersions] — אחד לכל גרסת אוצריא - * שהכונן נושא, בלי כפילות כששתיהן נפתרות לאותו בילד. + * שהמראה נבנית עבורה, בלי כפילות כששתיהן נפתרות לאותו בילד. * - * רשימת גרסאות ריקה = אין מול מה לסנן (מראת תוכנה ריקה), ואז יורד הבילד - * החי בלבד: בדיוק ההתנהגות שהייתה לפני שהתאימות נכנסה. + * רשימת גרסאות ריקה = אין מול מה לסנן (בירור הגרסאות נכשל), ואז יורד + * הבילד החי בלבד: בדיוק ההתנהגות שהייתה לפני שהתאימות נכנסה. + * + * **מוחזר ממוין יורד** ולא בסדר [appVersions]: הצרכנים בוחרים מתוך + * התוצאה את "הגבוה שמתאים למחשב הזה", והסדר הוא מה שהופך את זה לחד-משמעי. */ export function resolveTargets(entries, appVersions) { if (entries.length === 0) return []; @@ -55,7 +58,8 @@ export function resolveTargets(entries, appVersions) { const entry = resolveCompatibleVersion(entries, appVersion); if (entry !== null) targets.set(entry.version, entry); } - return [...targets.values()]; + return [...targets.values()] + .sort((a, b) => comparePluginVersions(b.version, a.version)); } /** diff --git a/native/web/js/store/mirror_store.js b/native/web/js/store/mirror_store.js index a8ad4bd..05c4483 100644 --- a/native/web/js/store/mirror_store.js +++ b/native/web/js/store/mirror_store.js @@ -10,8 +10,8 @@ // Data\catalog.json // Data\plugins\\{image.*, screenshot-N.*, plugin-.otzplugin} // -// **קובץ לכל בילד**, כי הכונן נושא עד שתי גרסאות של אוצריא ולכל אחת עשוי -// להתאים בילד אחר של אותו תוסף. `plugin.otzplugin` בלי גרסה הוא השם הישן +// **קובץ לכל בילד**, כי המראה נבנית עבור עד שתי גרסאות של אוצריא (ראו +// `otzaria_release_client.js`) ולכל אחת עשוי להתאים בילד אחר של אותו תוסף. `plugin.otzplugin` בלי גרסה הוא השם הישן // ונשאר קריא — ראו `StorePlugin.fromJson`. // // כל הנתיבים בקטלוג נשמרים **יחסית** ל-`plugins\`, כדי שהמראה תעבוד גם @@ -127,7 +127,7 @@ export class PluginMirrorStore { } /** - * מוחק קובצי בילד שכבר אינם בקטלוג — כשגרסת אוצריא שבכונן זזה, הבילד + * מוחק קובצי בילד שכבר אינם בקטלוג — כשגרסת היעד של אוצריא זזה, הבילד * שהתאים לקודמת אינו נחוץ עוד, וכונן נייד אינו המקום לצבור אותם. * מחזיר כמה נמחקו. */ diff --git a/native/web/js/store/mirror_sync.js b/native/web/js/store/mirror_sync.js index 2a9d9e2..dd26631 100644 --- a/native/web/js/store/mirror_sync.js +++ b/native/web/js/store/mirror_sync.js @@ -132,6 +132,12 @@ export class PluginMirrorSync { const catalog = new PluginCatalog({ lastSync: new Date(), + // מה שהמראה נבנתה עבורו, כדי שהמחשב הלא-מקוון יוכל להסביר תוסף + // שאינו מוצג אצלו. **בביטול נשמר הקודם**: הקבצים שעל הכונן הם + // עדיין אלה של היעד הקודם, ולא של זה שהסנכרון לא הספיק למלא. + targetAppVersions: wasCancelled + ? previousCatalog.targetAppVersions + : appVersions, plugins: plugins.map((plugin) => plugin.copyWith({categorySlugs: structure.slugsOf(plugin.id)})), categories: structure.categories, @@ -139,7 +145,7 @@ export class PluginMirrorSync { }); await this.store.save(catalog); - // בילד שכבר אינו בקטלוג (גרסת אוצריא שבכונן זזה) נמחק מהדיסק — אחרת + // בילד שכבר אינו בקטלוג (גרסת היעד של אוצריא זזה) נמחק מהדיסק — אחרת // כל עדכון של אוצריא היה מוסיף שכבת בילדים ישנים על הכונן הנייד. // **לא בביטול**: שם הקטלוג הוא הישן, והניקוי היה מוחק את מה שכן ירד. if (!wasCancelled) { @@ -189,7 +195,7 @@ export class PluginMirrorSync { manifestId: previous?.manifestId ?? null, }); - // בילד לכל גרסת אוצריא שהכונן נושא. בילד שכבר במראה נשמר, וזה שאינו + // בילד לכל גרסת יעד של אוצריא. בילד שכבר במראה נשמר, וזה שאינו // מבוקש עוד נושר מהקטלוג — הקובץ שלו נמחק בסוף הסנכרון. const keep = new Map(); const missing = []; @@ -388,7 +394,7 @@ export class PluginMirrorSync { } /** - * מוריד את הבילדים שהתכנון סימן — אחד לכל גרסת אוצריא שהכונן נושא + * מוריד את הבילדים שהתכנון סימן — אחד לכל גרסת יעד של אוצריא * ושהבילד שלה עוד לא במראה. `ok: false` = לפחות אחד מהם נכשל. */ async #syncPluginFiles(plan, plugin, report) { diff --git a/native/web/js/store/models.js b/native/web/js/store/models.js index 0af019c..0a928f4 100644 --- a/native/web/js/store/models.js +++ b/native/web/js/store/models.js @@ -189,7 +189,7 @@ function versionsFrom(value, baseUrl) { * מיזוג של המטא-דאטה מ-`/api/plugins` עם הנתיבים היחסיים של הקבצים * שירדו למראה. * - * **הקבצים הם מפה, לא קובץ אחד** (`localFiles`): הכונן נושא עד שתי + * **הקבצים הם מפה, לא קובץ אחד** (`localFiles`): המראה נבנית עבור עד שתי * גרסאות של אוצריא, ולכל אחת עשוי להתאים בילד אחר של אותו תוסף. */ export class StorePlugin { @@ -317,13 +317,42 @@ export class StorePlugin { } /** - * הבילד שיותקן במחשב שמריץ [appVersion]: הגבוה ביותר שתואם לו - * **ושהקובץ שלו יושב במראה**. כשאף בילד תואם לא ירד מוחזר הגבוה - * שתואם, כדי שהממשק יאמר "הקובץ לא ירד" ולא "אין תוסף". + * הבילדים שהמראה **אמורה** לנשוא — אחד לכל גרסה ב-[targetAppVersions], + * ממוינים יורד. בלי תלות בשאלה אם הקובץ שלהם כבר ירד. + * + * רשימה ריקה של גרסאות יעד = מראה שנכתבה לפני שהיעד נשמר בקטלוג, או + * בירור שנכשל. אז **כל** הבילדים הם מועמדים, כמו לפני השינוי: מראה + * ותיקה נושאת בילד שנבחר לפי גרסת המחשב המסנכרן, ופסילתו הייתה מסתירה + * תוסף שקובץ עובד שלו יושב על הכונן. + */ + mirrorTargets(targetAppVersions = []) { + return targetAppVersions.length > 0 + ? resolveTargets(this.versionEntries, targetAppVersions) + : this.versionEntries; + } + + /** + * האם המראה נושאת בילד שירוץ על [appVersion] — התנאי להצגת התוסף + * בחנות. `false` = התוסף אינו מוצג כלל: אין מה להציע למחשב הזה, וכל + * מה שהצגתו הייתה מייצרת הוא כפתור התקנה שדורש אינטרנט במחשב שאין בו. + */ + runsOn(appVersion, targetAppVersions = []) { + return this.mirrorTargets(targetAppVersions) + .some((entry) => isCompatibleWithApp(entry, appVersion)); + } + + /** + * הבילד שיותקן במחשב שמריץ [appVersion]: הגבוה **מבין אלה שהמראה + * נושאת** שתואם לו ושהקובץ שלו יושב במראה. כשאף אחד מהם לא ירד מוחזר + * הגבוה שתואם, כדי שהממשק יאמר "הקובץ לא ירד" ולא "אין תוסף". + * + * ⚠️ **רק מתוך בילדי המראה**, ולא מכל ההיסטוריה: בילד היסטורי שתואם + * למחשב הזה אך אינו יעד של המראה לא יירד לעולם, והחזרתו הייתה מייצרת + * "טרם ירד — יש לבצע סנכרון" שאין דרך לפתור. */ - installTarget(appVersion) { + installTarget(appVersion, targetAppVersions = []) { let compatible = null; - for (const entry of this.versionEntries) { + for (const entry of this.mirrorTargets(targetAppVersions)) { if (!isCompatibleWithApp(entry, appVersion)) continue; if (compatible === null) compatible = entry; if (this.localFiles.has(entry.version)) return entry; @@ -331,7 +360,7 @@ export class StorePlugin { return compatible; } - /** הבילדים שצריכים לרדת עבור הגרסאות שהכונן נושא. */ + /** הבילדים שצריכים לרדת עבור גרסאות היעד של המראה. */ targetsFor(appVersions) { return resolveTargets(this.versionEntries, appVersions); } @@ -350,8 +379,8 @@ export class StorePlugin { * * @param {Map|Object} installed */ - statusAgainst(installed, appVersion) { - const target = this.installTarget(appVersion); + statusAgainst(installed, appVersion, targetAppVersions = []) { + const target = this.installTarget(appVersion, targetAppVersions); if (target === null) return InstallStatus.incompatible; const key = this.manifestId; @@ -651,10 +680,22 @@ export class PluginStoreHome { export class PluginCatalog { constructor({lastSync = null, plugins = [], categories = [], - home = PluginStoreHome.empty} = {}) { + home = PluginStoreHome.empty, targetAppVersions = []} = {}) { /** מועד הסנכרון האחרון, או null אם מעולם לא סונכרן. */ this.lastSync = lastSync; this.plugins = plugins; + /** + * גרסאות אוצריא שהסנכרון בנה את המראה עבורן, מהגבוהה לנמוכה — מה + * שהריפו פרסם באותו רגע, ולא הגרסה שבמחשב המסנכרן. + * + * **נשמר בקטלוג** כי המחשב שצורך את המראה אינו מקוון ואינו יכול + * לברר אותן בעצמו: בלעדיהן אין דרך לדעת אם תוסף חסר כי הוא אינו + * תואם למחשב הזה, ואין מה לומר למשתמש. + * + * ריק במראה שסונכרנה לפני שהשדה נוסף — ואז אין הסתרה, ראו + * `StorePlugin.mirrorTargets`. + */ + this.targetAppVersions = targetAppVersions; /** * קטגוריות החנות, בסדר שנקבע באתר. ריק במראה שסונכרנה לפני שהאתר * הכניס קטגוריות, וזה מצב תקין — הממשק פשוט לא מציג שורת קטגוריות. @@ -675,6 +716,7 @@ export class PluginCatalog { toJSON() { return { lastSync: this.lastSync === null ? null : this.lastSync.toISOString(), + targetAppVersions: this.targetAppVersions, home: this.home.toJSON(), categories: this.categories.map((c) => c.toJSON()), plugins: this.plugins.map((p) => p.toJSON()), @@ -716,6 +758,11 @@ export class PluginCatalog { lastSync, plugins, categories, + // מסונן: ערך שאינו מספר גרסה היה מסתיר תוספים בלי סיבה. + targetAppVersions: Array.isArray(json.targetAppVersions) + ? json.targetAppVersions.filter( + (v) => typeof v === 'string' && v.length > 0) + : [], home: PluginStoreHome.fromJson(json.home), }); } diff --git a/native/web/js/store/online_peek.js b/native/web/js/store/online_peek.js index 791e192..952e6c8 100644 --- a/native/web/js/store/online_peek.js +++ b/native/web/js/store/online_peek.js @@ -45,7 +45,7 @@ export class PluginOnlinePeek { * גרסאות הבילד שהקובץ שלהן נמצא בפועל במראה. * * השאלה הנשאלת כאן היא **בדיוק** זו של `PluginMirrorSync.#plan`: לכל - * גרסת אוצריא שהכונן נושא נבחר הבילד התואם, ונבדק אם הוא כבר במראה. כל + * גרסת יעד של אוצריא נבחר הבילד התואם, ונבדק אם הוא כבר במראה. כל * השוואה "חכמה" אחרת הייתה מדווחת עדכון שסנכרון לא מביא, או להפך. * * חשוף בנפרד כדי שבדיקות יאמתו אותו בלי רשת ובלי דיסק. diff --git a/native/web/js/store/otzaria_probe.js b/native/web/js/store/otzaria_probe.js index b7ed07f..c40725f 100644 --- a/native/web/js/store/otzaria_probe.js +++ b/native/web/js/store/otzaria_probe.js @@ -46,6 +46,12 @@ const OUR_OWN_EXE_NAMES = new Set([ 'otzaria-updates', 'otzaria_plugin_store', 'otzaria-plugin-store', + // ⚠️ **גם החבילה המלאה.** `nameLooksLikeOtzaria` מתאים ל-`otzaria` + // הלטיני, וקובץ ההתקנה של החבילה שנשאר בתיקייה נסרקת (מישהו הריץ + // אותו בתוך `C:\אוצריא`) היה נבחר שם כהתקנה של אוצריא עצמה — והחנות + // הייתה מציעה להתקין תוספים לתוך עצמה. הוא **אינו** מכוסה ע"י + // `selfPath`: מה שרץ אחרי הפריסה הוא ה-exe הרזה, לא הוא. + 'otzaria-plugin-store-full', 'חנות התוספים', ]); diff --git a/native/web/js/store/otzaria_release_client.js b/native/web/js/store/otzaria_release_client.js new file mode 100644 index 0000000..a22aa15 --- /dev/null +++ b/native/web/js/store/otzaria_release_client.js @@ -0,0 +1,141 @@ +// מברר מהן גרסאות **אוצריא** שהמראה נבנית עבורן, מתוך ה-releases של +// `Otzaria/otzaria`. +// +// ── למה בכלל, ולא הגרסה שבמחשב ─────────────────────────────────────────────── +// המראה נבנית במחשב מקוון ונצרכת במחשב **אחר**, לא-מקוון. סינון לפי +// הגרסה שבמחשב המסנכרן פירושו שכונן שנבנה על מחשב עם אוצריא ישנה נושא +// בילדים ישנים, ומחשב היעד — שגרסתו אחרת — מקבל "טרם ירד, יש לבצע +// סנכרון" בדיוק במקום שאין בו אינטרנט. לכן היעד הוא מה שהריפו מפרסם, +// ולא מה שמותקן כאן. +// +// ── שתי גרסאות, ולא אחת ────────────────────────────────────────────────────── +// כשה-release האחרון מסומן `prerelease`, מי שעל ערוץ היציב אינו מריץ +// אותו — ובילד שנבחר לפיו לא בהכרח יעלה אצלו. לכן במצב הזה מתבררות +// **שתיהן**: האחרונה, והיציבה האחרונה. `resolveTargets` יגזור מהן בילד +// לכל אחת, וכשהשתיים נפתרות לאותו בילד יורד אחד. +// +// ⚠️ **הדגל, לא השם.** בריפו הזה כל ה-releases נקראים +// "Otzaria X (Preview from dev)" — כולל אלה שאינם prerelease. השם אינו +// סימן לכלום, והדגל `prerelease` הוא הנתון היחיד שאפשר להכריע לפיו. + +import {comparePluginVersions} from '../util/version.js'; + +const OWNER = 'Otzaria'; +const REPO = 'otzaria'; +const API_BASE = 'https://api.github.com'; + +/** + * דף אחד. הגרסה האחרונה והיציבה האחרונה נמצאות בראש הרשימה בפועל, ודף + * שני היה מאריך את הסנכרון בשביל היסטוריה שאינה מעניינת אותנו. + */ +const PAGE_SIZE = 30; + +/** + * תג גרסה של אוצריא: `0.9.96+736`, `v0.9.96`, `0.9.96`. + * + * ⚠️ מקטע prerelease (`-pr-715`, `-dev-110`) **נפסל**: אלה בילדים של + * בקשות משיכה ושל dev, ולא גרסה שמישהו מריץ. הם אמורים לשאת גם את הדגל + * `prerelease`, אבל בריפו הזה יש בפועל תגים כאלה בלי הדגל — ולכן התג + * נבדק בנפרד ולא נסמך עליו. + */ +const RELEASE_TAG = /^v?(\d+(?:\.\d+)*)(?:\+\d+)?$/; + +/** דף ה-releases, כשצריך להצביע על מקום ולא על גרסה. */ +export const OTZARIA_RELEASES_PAGE_URL = + `https://github.com/${OWNER}/${REPO}/releases`; + +/** + * הגרסה שבתוך התג, בלי `v` ובלי מספר הבילד (`+736`), או `null` כשאינו + * תג גרסה. + * + * מספר הבילד נחתך כי הוא **אינו** חלק מהגרסה שהתוספים מצהירים עליה: + * `compatibleWith` באתר הוא `0.9.96`, וההשוואה מולו צריכה להיות מול + * אותו מספר בדיוק. + */ +export function otzariaVersionOf(tag) { + const match = RELEASE_TAG.exec(String(tag ?? '').trim()); + return match === null ? null : match[1]; +} + +export class OtzariaReleaseClient { + constructor({net, timeoutMs = 20000}) { + this.net = net; + /** + * זמן קצוב — חובה: בלעדיו מחשב שמחובר לרשת בלי מסלול לאינטרנט היה + * תולה את הסנכרון כולו לפני שהוא מתחיל. + */ + this.timeoutMs = timeoutMs; + } + + /** + * הגרסאות שהמראה תיבנה עבורן, מהגבוהה לנמוכה: הגרסה האחרונה, ואם היא + * `prerelease` — גם היציבה האחרונה שמתחתיה. + * + * רשימה **ריקה** כשאין אף release עם תג גרסה. המתקשר מתייחס אליה + * כ"אין מול מה לסנן", בדיוק כמו לכשל. + * + * @returns {Promise<{versions: string[], latest: string|null, + * latestStable: string|null, + * latestIsPrerelease: boolean}>} + */ + async fetchTargetVersions() { + const url = + `${API_BASE}/repos/${OWNER}/${REPO}/releases?per_page=${PAGE_SIZE}`; + // ⚠️ GitHub מחזיר 403 לכל בקשה בלי User-Agent. ה-host קובע אותו + // ברמת הסשן (`kUserAgent` ב-netapi.cpp), ולכן אין צורך בכותרת כאן. + const response = await this.net.get(url, this.timeoutMs); + if (response.status !== 200) { + throw new Error(`GitHub החזיר ${response.status} עבור ${url}`); + } + + const decoded = JSON.parse(response.body); + if (!Array.isArray(decoded)) { + throw new Error(`אין releases ב-${OWNER}/${REPO}`); + } + return pickTargetVersions(decoded); + } +} + +/** + * הבחירה עצמה, בלי רשת — חשוף כדי שבדיקות יאמתו אותה על תשובה מוקלטת. + * + * הגבוהה ולא הראשונה ברשימה: סדר הפרסום אינו סדר הגרסאות (release + * שנערך ידנית, תיקון שפורסם לגרסה ותיקה אחרי החדשה), ובחירת "הראשון" + * הייתה תלוית-מזל. + * + * @param {Array} releases תשובת `/releases` של GitHub + */ +export function pickTargetVersions(releases) { + let latest = null; + let latestStable = null; + + for (const entry of releases) { + if (entry === null || typeof entry !== 'object') continue; + // draft אינו קיים לציבור; תג שאינו גרסה (או בילד PR/dev) אינו גרסה + // שמישהו מריץ. + if (entry.draft === true) continue; + const version = otzariaVersionOf(entry.tag_name); + if (version === null) continue; + + if (latest === null || comparePluginVersions(version, latest) > 0) { + latest = version; + } + if (entry.prerelease !== true && + (latestStable === null || + comparePluginVersions(version, latestStable) > 0)) { + latestStable = version; + } + } + + const latestIsPrerelease = latest !== null && + (latestStable === null || comparePluginVersions(latest, latestStable) > 0); + + // סדר יורד, כדי שהצרכנים יקבלו את הגרסאות במפורש מהגבוהה לנמוכה. + const versions = []; + if (latest !== null) versions.push(latest); + if (latestIsPrerelease && latestStable !== null) { + versions.push(latestStable); + } + + return {versions, latest, latestStable, latestIsPrerelease}; +} diff --git a/native/web/js/store/plugins_manager.js b/native/web/js/store/plugins_manager.js index 740cade..33f1780 100644 --- a/native/web/js/store/plugins_manager.js +++ b/native/web/js/store/plugins_manager.js @@ -96,15 +96,16 @@ export class PluginsManager { } /** שם הקובץ המוצע לשמירה, לפי מה שהאתר החזיר ב-Content-Disposition. */ - suggestedFileName(plugin, appVersion) { - const local = plugin.localFileFor(plugin.installTarget(appVersion)?.version) - ?? plugin.anyLocalFile; + suggestedFileName(plugin, appVersion, targetAppVersions = []) { + const target = plugin.installTarget(appVersion, targetAppVersions); + const local = plugin.localFileFor(target?.version) ?? plugin.anyLocalFile; return local?.fileName ?? `${plugin.name}${local?.ext ?? '.otzplugin'}`; } /** מעתיק את קובץ ה-.otzplugin ליעד שהמשתמש בחר. */ - async saveCopy(plugin, destPath, appVersion) { - const local = plugin.localFileFor(plugin.installTarget(appVersion)?.version); + async saveCopy(plugin, destPath, appVersion, targetAppVersions = []) { + const target = plugin.installTarget(appVersion, targetAppVersions); + const local = plugin.localFileFor(target?.version); if (local === null || !await this.store.hasAsset(local.relativePath)) { return failure(S.domain.fileNotAvailableSyncFirst); } @@ -128,8 +129,8 @@ export class PluginsManager { * אם קובץ התוסף חסר מהמראה הוא מורד עכשיו — וזה הצעד היחיד כאן שדורש * אינטרנט. */ - async install(plugin, appVersion) { - const target = plugin.installTarget(appVersion); + async install(plugin, appVersion, targetAppVersions = []) { + const target = plugin.installTarget(appVersion, targetAppVersions); if (target === null) return failure(S.domain.noCompatibleBuild); let local = plugin.localFileFor(target.version); diff --git a/native/web/js/strings.js b/native/web/js/strings.js index b0071d6..246934c 100644 --- a/native/web/js/strings.js +++ b/native/web/js/strings.js @@ -118,6 +118,30 @@ export const S = Object.freeze({ noResultsBody: 'נסו לחפש בשם אחר, להסיר תגית, לבחור סטטוס שונה, או לכבות את ' + '"הצג רק מה שלא מותקן".', + // ── תוספים שהוסתרו בגלל תאימות ───────────────────────────────────── + // המראה נבנית עבור מה שהריפו של אוצריא פרסם, ולא עבור המחשב הזה. + // תוסף שאין בה בילד שירוץ כאן אינו מוצג — ובלי השורה הזאת חנות + // שחסרים בה תוספים אינה ניתנת לאבחון מרחוק. + hiddenByCompatibility: (count, appVersion, targetVersions) => { + const what = count === 1 + ? 'תוסף אחד אינו מוצג — אין במראה גרסה שלו שתואמת' + : `${count} תוספים אינם מוצגים — אין במראה גרסה שלהם שתואמת`; + const here = appVersion + ? `לאוצריא ${appVersion} שבמחשב הזה` + : 'לגרסת אוצריא שבמחשב הזה'; + const built = targetVersions.length === 0 ? '' : + ` המראה נבנתה עבור אוצריא ${targetVersions.join(' ו-')}.`; + return `${what} ${here}.${built}`; + }, + hiddenOnlyTitle: 'התוספים שבמראה אינם תואמים לאוצריא שבמחשב הזה', + hiddenOnlyBody: (count, appVersion, targetVersions) => { + const built = targetVersions.length === 0 ? '' : + ` המראה נבנתה עבור אוצריא ${targetVersions.join(' ו-')}.`; + const here = appVersion ? ` כאן מותקנת ${appVersion}.` : ''; + return `המראה נושאת ${count} תוספים, ולאף אחד מהם אין גרסה שתרוץ ` + + `על אוצריא שבמחשב הזה.${built}${here} עדכון אוצריא יציג אותם.`; + }, + allInstalledTitle: 'הכול מותקן ומעודכן', allInstalledBody: 'המתג "רק מה שלא מותקן" מסתיר תוספים שכבר מותקנים אצלכם בגרסה ' + @@ -279,6 +303,19 @@ export const S = Object.freeze({ openFailed: (url) => `פתיחת הדפדפן נכשלה. הכתובת להורדה: ${url}`, }), + // ── קובץ ההתקנה של החבילה המלאה ─────────────────────────────────────────── + // מוצג פעם אחת, אחרי שהחבילה פרסה את עצמה והעבירה את השרביט ל-exe + // הרזה. ⚠️ **שאלה ולא מחיקה**: אותו קובץ עשוי להיות מיועד גם למחשב + // שני, ומחיקה שקטה של הורדה של ~‎96MB הייתה מאלצת להוריד אותה שוב. + bundleInstaller: Object.freeze({ + bannerTitle: (size) => + `כל התוספים נפרסו. קובץ ההתקנה (${size}) אינו נחוץ עוד`, + bannerButton: 'מחיקת קובץ ההתקנה', + bannerDismissTooltip: 'להשאיר אותו', + deleted: 'קובץ ההתקנה נמחק', + deleteFailed: (error) => `מחיקת קובץ ההתקנה נכשלה: ${error}`, + }), + // ── כונן מוגן מפני כתיבה ────────────────────────────────────────────────── readOnlyDrive: Object.freeze({ bannerTitle: 'הכונן מוגן מפני כתיבה — מצב קריאה', diff --git a/native/web/js/ui/screens.js b/native/web/js/ui/screens.js index 4cb10d8..c3af32f 100644 --- a/native/web/js/ui/screens.js +++ b/native/web/js/ui/screens.js @@ -115,13 +115,21 @@ export function renderStore(host, controller, {readOnly = false} = {}) { h('span', {text: S.plugins.loadingCatalog})], }))); } else if (controller.plugins.length === 0) { - scroll.append(h('div.store__pad', neverSyncedState(controller, readOnly))); + // מראה מלאה שאף תוסף בה אינו רץ כאן אינה "מעולם לא סונכרן" — הסנכרון + // דווקא הצליח, וההסבר היחיד שעוזר הוא שאוצריא כאן אינה מתאימה. + scroll.append(h('div.store__pad', controller.hiddenCount > 0 + ? hiddenOnlyState(controller) + : neverSyncedState(controller, readOnly))); } else { switch (controller.view) { case Page.home: scroll.append(...homeSections(controller)); break; case Page.all: scroll.append(...allSections(controller)); break; case Page.category: scroll.append(...categorySections(controller)); break; } + // בתחתית **כל** המסכים: התוסף החסר עשוי להיות מאוצר בדף הבית או + // משובץ בקטגוריה, ולא רק ברשימה המלאה. + const hidden = hiddenNote(controller); + if (hidden !== null) scroll.append(hidden); } replace(host, layout); @@ -543,8 +551,43 @@ function categorySections(controller) { return sections; } +// ── תוספים שהוסתרו בגלל תאימות ─────────────────────────────────────────────── + +/** + * שורת ההסבר בתחתית הרשימה, או `null` כשלא הוסתר כלום. + * + * **שורה אחת ולא התראה**: זה מצב תקין של מראה שנבנתה עבור גרסה אחרת, לא + * תקלה — אבל בלי שום אזכור, "התוסף קיים באתר ואצלי הוא נעלם" אינו ניתן + * לאבחון מרחוק, וזו בדיוק השאלה שמגיעה מהמשתמשים. + */ +function hiddenNote(controller) { + if (controller.hiddenCount === 0) return null; + return h('div.store__pad', + appCard({ + className: 'hidden-note', + children: [ + icon('info', 20), + h('span.hidden-note__text', { + text: S.plugins.hiddenByCompatibility( + controller.hiddenCount, controller.appVersion, + controller.targetAppVersions), + }), + ], + })); +} + // ── מצבים ריקים ────────────────────────────────────────────────────────────── +/** מראה שאין בה אף תוסף שירוץ על אוצריא שכאן. */ +function hiddenOnlyState(controller) { + const t = S.plugins; + return emptyState({ + title: t.hiddenOnlyTitle, + body: t.hiddenOnlyBody(controller.hiddenCount, controller.appVersion, + controller.targetAppVersions), + }); +} + function neverSyncedState(controller, readOnly) { const t = S.plugins; return emptyState({