commit 6ac95e43cb5a3bc9b5407cfd17f64f0c3ba1c423 Author: Simone Date: Wed Apr 29 14:06:20 2026 +0200 chore: initial commit (extracted from Launchers monorepo) Plugin: UniversalShellExt v1.0.0 Architectures: x64 License: MIT diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..55629c5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{cmd,bat,ps1}] +end_of_line = crlf + +[*.{nsi,nsh,sln,vcxproj,filters}] +end_of_line = crlf + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..20b175b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Normalizzazione line endings +* text=auto eol=lf + +# File di codice +*.c text eol=lf +*.cpp text eol=lf +*.h text eol=lf +*.hpp text eol=lf +*.def text eol=lf +*.py text eol=lf +*.ps1 text eol=lf +*.cmd text eol=crlf +*.bat text eol=crlf + +# NSIS +*.nsi text eol=crlf +*.nsh text eol=crlf + +# Markdown / docs / config +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.txt text eol=lf +*.xml text eol=lf + +# Visual Studio (CRLF richiesto) +*.sln text eol=crlf +*.vcxproj text eol=crlf merge=union +*.filters text eol=crlf merge=union +*.rc binary +*.rc2 binary + +# Binari +*.dll binary +*.exe binary +*.lib binary +*.obj binary +*.pdb binary +*.zip binary +*.7z binary +*.png binary +*.ico binary +*.gif binary +*.jpg binary +*.jpeg binary +*.bmp binary +*.pdf binary + +# Linguistic detection (GitHub Linguist / Gitea analogo) +*.nsi linguist-language=NSIS +*.nsh linguist-language=NSIS diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7599f2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Segnala un bug riproducibile +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Grazie per la segnalazione. Compila i campi sotto per aiutarci a riprodurre il problema. + + - type: input + id: plugin-version + attributes: + label: Versione plugin + description: Contenuto del file VERSION o tag della release scaricata + placeholder: "1.6.0" + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: Architettura DLL usata + options: + - x86-ansi + - x86-unicode + - amd64-unicode + - non so / multiple + validations: + required: true + + - type: input + id: nsis-version + attributes: + label: Versione NSIS + placeholder: "3.10" + validations: + required: true + + - type: input + id: os + attributes: + label: Sistema operativo + placeholder: "Windows 11 23H2 x64" + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Passi per riprodurre + description: Script .nsi minimale che riproduce il problema + render: nsis + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Comportamento atteso + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Comportamento osservato + description: Includi messaggi di errore esatti, output del log NSIS, screenshot + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Note aggiuntive + description: Qualunque altra informazione utile (workaround tentati, ecc.) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1e6f7cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Domande generali + url: https://gitea.emulab.it/Simone/universal-shell-ext/issues?type=question + about: Per domande di utilizzo che non sono bug, apri una issue con label "question" + - name: Security + url: https://gitea.emulab.it/Simone/universal-shell-ext/security/advisories/new + about: Segnala vulnerabilità di security in privato (non aprire issue pubbliche) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..fae6d8d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature Request +description: Suggerisci una nuova funzionalità o miglioramento +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problema / Caso d'uso + description: Quale problema vorresti risolvere? Quale workflow vuoi abilitare? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposta + description: Come dovrebbe funzionare? Includi esempio di API/uso negli script NSIS se possibile. + render: nsis + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternative considerate + description: Altri approcci che hai valutato e perché sono stati scartati + + - type: textarea + id: additional + attributes: + label: Note aggiuntive diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3758b50 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ +## Descrizione + + + +## Tipo di Modifica + +- [ ] Bug fix (modifica non breaking che risolve un problema) +- [ ] Feature (modifica non breaking che aggiunge funzionalità) +- [ ] Breaking change (fix o feature che rompe la compatibilità) +- [ ] Documentazione +- [ ] Build / CI + +## Architetture Testate + +- [ ] `x86-ansi` +- [ ] `x86-unicode` +- [ ] `amd64-unicode` + +## Checklist + +- [ ] Build completa senza warning: `python build_plugin.py` +- [ ] CHANGELOG.md aggiornato (sezione `[Unreleased]`) +- [ ] README / docs aggiornati se l'API è cambiata +- [ ] Esempi aggiornati se l'API è cambiata +- [ ] Conventional Commits rispettato +- [ ] Nessun file in `dist/` o `__pycache__/` committato +- [ ] Self-review completata + +## Note Aggiuntive + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a521284 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,63 @@ +# Build (GitHub) — pipeline CI primaria +# +# Strategia hosting: +# - Repo primario su Gitea (push iniziale) +# - Push mirror automatico Gitea -> GitHub +# - GitHub esegue TUTTA la CI (Gitea non esegue Actions) +# +# Trigger: +# - push su main (incluso quello propagato dal mirror Gitea) +# - PR aperte su GitHub +# - run manuale via UI + +name: Build + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + - 'examples/**' + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + config: [x86-ansi, x86-unicode, x64-unicode] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Read VERSION + id: version + shell: pwsh + run: | + $v = (Get-Content VERSION -Raw).Trim() + "version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Build ${{ matrix.config }} + run: python build_plugin.py --config ${{ matrix.config }} --verbose + + - name: Upload DLL + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}-${{ steps.version.outputs.version }}-${{ matrix.config }} + path: dist/** + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e4cf67d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +# Release (GitHub) — pipeline release primaria +# +# Triggerata quando un tag v* viene propagato dal mirror Gitea. +# Costruisce gli artifact per tutte le architetture e crea la GitHub Release. + +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag versione (es. v1.7.0)' + required: true + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + strategy: + fail-fast: true + matrix: + config: [x86-ansi, x86-unicode, x64-unicode] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - uses: microsoft/setup-msbuild@v2 + + - name: Build ${{ matrix.config }} + run: python build_plugin.py --config ${{ matrix.config }} + + - name: Determine version & arch + id: meta + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + if ([string]::IsNullOrEmpty($tag) -or $tag -eq "main") { $tag = "${{ inputs.tag }}" } + $v = $tag -replace '^v','' + $map = @{ 'x86-ansi' = 'x86-ansi'; 'x86-unicode' = 'x86-unicode'; 'x64-unicode' = 'amd64-unicode' } + $dir = $map['${{ matrix.config }}'] + echo "version=$v" >> $env:GITHUB_OUTPUT + echo "tag=$tag" >> $env:GITHUB_OUTPUT + echo "dir=$dir" >> $env:GITHUB_OUTPUT + + - name: Package + shell: pwsh + run: | + $name = "${{ github.event.repository.name }}-${{ steps.meta.outputs.version }}-${{ steps.meta.outputs.dir }}" + $stage = "stage/$name" + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item "dist/${{ steps.meta.outputs.dir }}/*" $stage -Recurse + Copy-Item README.md, LICENSE, VERSION, CHANGELOG.md $stage + if (Test-Path "include") { Copy-Item "include" $stage -Recurse } + if (Test-Path "examples") { Copy-Item "examples" $stage -Recurse } + Compress-Archive -Path "$stage/*" -DestinationPath "$name.zip" -Force + + - uses: actions/upload-artifact@v4 + with: + name: pkg-${{ matrix.config }} + path: '*.zip' + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: SHA256SUMS + run: | + cd artifacts + sha256sum *.zip > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/*.zip + artifacts/SHA256SUMS.txt + generate_release_notes: true + body: | + **Repository primario**: vedi link Gitea nel README. + + Il mirror GitHub è generato automaticamente dal repo Gitea (sorgente di verità). + Per issue e PR aprire ticket sul repository primario Gitea. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c3a730 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Build output (src/ subdirs only — dist/ is tracked, contains pre-built DLLs) +bin/ +obj/ +Build/ +build/ + +# Visual Studio +*.user +*.suo +*.ncb +*.sdf +.vs/ +ipch/ +*.VC.db +*.VC.VC.opendb +*.opendb +*.aps +*.tlog +*.idb +*.pdb +*.ilk +*.exp +*.lib +*.obj + +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +*.egg-info/ +.venv/ +venv/ + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +Thumbs.db +desktop.ini +.DS_Store + +# NSIS +*.nsh.bak + +# Logs +*.log +.logs/ + +# Sensitive +.env +.env.local +*.pem +*.key + +# Migration/automation scripts (temporary, contain tokens — never commit) +tools/t*.py +tools/check_*.py +tools/legacy/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49c81d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +Tutte le modifiche rilevanti a questo progetto sono documentate in questo file. + +Il formato segue [Keep a Changelog](https://keepachangelog.com/it/1.0.0/), +e il progetto aderisce al [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] — 2026-04-29 + +### Added +- Prima release come repository indipendente +- Build script unificato (`build_plugin.py`) con CLI canonica +- CI/CD via GitHub Actions (mirror automatico Gitea → GitHub) +- Documentazione completa (README, CONTRIBUTING, SECURITY) + +[Unreleased]: https://gitea.emulab.it/Simone/universal-shell-ext/compare/v1.0.0...HEAD +[1.0.0]: https://gitea.emulab.it/Simone/universal-shell-ext/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7b68568 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contribuire a UniversalShellExt + +Grazie per il tuo interesse a contribuire! + +> Repository primario: [https://gitea.emulab.it/Simone/universal-shell-ext](https://gitea.emulab.it/Simone/universal-shell-ext) +> Mirror GitHub: [https://github.com/systempal/universal-shell-ext](https://github.com/systempal/universal-shell-ext) (read-only — apri PR su Gitea) + +## Prerequisiti + +| Strumento | Versione | Note | +|-----------|----------|------| +| Visual Studio | 2022 / 2026 | Workload: Desktop development with C++ (toolset v143/v145) | +| Python | 3.7+ | Per `build_plugin.py` | +| Git | 2.30+ | Con supporto submoduli se applicabile | + +## Quick Start + +```powershell +git clone https://gitea.emulab.it/Simone/universal-shell-ext.git +cd universal-shell-ext +python build_plugin.py +``` + +Output in `dist/{x86-ansi,x86-unicode,amd64-unicode}/`. + +## Workflow Sviluppo + +### 1. Branch + +```powershell +git checkout -b feat/nome-feature # nuova feature +git checkout -b fix/descrizione-bug # bugfix +git checkout -b docs/aggiornamento # solo doc +git checkout -b chore/manutenzione # build, dipendenze +``` + +### 2. Commit + +Segui [Conventional Commits](https://www.conventionalcommits.org/it/v1.0.0/): + +``` +feat: aggiunge supporto per formato XYZ +fix: corregge crash su Windows 11 ARM +docs: aggiorna esempio di utilizzo +chore: aggiorna toolset MSVC +refactor: estrae logica parsing in funzione separata +test: aggiunge test per edge case +build: aggiorna script di build +ci: aggiorna workflow GitHub Actions +``` + +Breaking changes: + +``` +feat!: rinomina API Foo() in Bar() + +BREAKING CHANGE: Foo() rinominata in Bar(); aggiornare tutti i chiamanti. +``` + +### 3. Pull Request + +PR aperte verso il branch `main` su Gitea. Verifica: + +- [ ] Build completa senza warning per tutte le architetture: `python build_plugin.py` +- [ ] Esempi aggiornati se cambia l'API +- [ ] `CHANGELOG.md` aggiornato (sezione `[Unreleased]`) +- [ ] Documentazione aggiornata (README, docs/API.md se esiste) +- [ ] Conventional Commits rispettato +- [ ] Nessun file in `dist/` o `__pycache__/` committato + +## Build Script + +``` +python build_plugin.py [opzioni] +``` + +| Opzione | Default | Descrizione | +|---------|---------|-------------| +| `--config` | `all` | Architettura: `x86-ansi`, `x86-unicode`, `x64-unicode`, `all` | +| `--toolset` | `auto` | Visual Studio: `2022`, `2026`, `auto` (rileva il più recente) | +| `--jobs` | numero CPU | Thread paralleli per la build | +| `--clean` | false | Pulisce `dist/` prima di compilare | +| `--install-dir` | none | Copia DLL anche in `/{arch}/` (es. NSIS plugin folder) | +| `--verbose` | false | Output dettagliato (mostra comandi MSBuild) | + +Exit codes: +- `0` — Successo +- `1` — Errore di build +- `2` — Prerequisiti mancanti +- `3` — Argomenti invalidi + +## Release Workflow + +Le release sono automatiche via tag Git: + +```powershell +# 1. Aggiorna VERSION e CHANGELOG.md +echo "1.7.0" > VERSION +# (modifica CHANGELOG.md spostando entry da [Unreleased] a [1.7.0]) + +# 2. Commit +git add VERSION CHANGELOG.md +git commit -m "chore(release): v1.7.0" + +# 3. Tag annotato +git tag -a v1.7.0 -m "Release v1.7.0" + +# 4. Push +git push origin main +git push origin v1.7.0 +``` + +Il workflow `.github/workflows/release.yml` compilerà tutte le architetture +e creerà la release su Gitea con gli artifact ZIP allegati. Il mirror GitHub +riceverà automaticamente il tag. + +## Code Style + +- C/C++: stile coerente con il file modificato +- Python: PEP 8 (4 spazi indent, line length flessibile) +- Header file: `#include` ordinati: stdlib → Win32 → progetto +- Encoding: UTF-8 senza BOM (eccetto `.rc` che richiede UTF-16 LE) +- Line endings: LF in repo (`.gitattributes` normalizza) + +## Reporting Bug + +Apri una [issue](https://gitea.emulab.it/Simone/universal-shell-ext/issues/new) usando il template "Bug Report". +Includi: +- Versione plugin e architettura DLL usata +- Versione NSIS +- Sistema operativo +- Script `.nsi` minimale che riproduce il problema +- Output / errore esatto + +## Domande + +Per domande generali, apri una [issue](https://gitea.emulab.it/Simone/universal-shell-ext/issues) con label `question`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e05be1c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Simone + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8eab52 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# UniversalShellExt + +Shell Extension COM generica per Windows che aggiunge una voce personalizzabile al menu contestuale di cartelle. + +## Funzionalità + +- ✅ **Menu Contestuale Personalizzabile**: + - Voce nel menu contestuale per cartelle (click destro su cartella) + - Voce nel menu contestuale per sfondo cartella (click destro su area vuota) + - Testo, icona e comando completamente configurabili via registro +- ✅ **Configurazione Flessibile**: + - Tutti i parametri letti dal registro di Windows + - Supporto per argomenti personalizzati con placeholder `%1` + - Icona estratta automaticamente dall'eseguibile +- ✅ **Sicurezza e Compatibilità**: + - Supporto UAC elevation automatico se necessario + - Compatibile con Windows 10/11 (x64) + - Accesso registro WOW64 corretto (64-bit) + - Thread-safe + +## File del Progetto + +| File | Descrizione | +|------|-------------| +| `UniversalShellExt.cpp` | Codice sorgente C++ della Shell Extension COM | +| `UniversalShellExt.def` | Definizioni esportazioni DLL | +| `UniversalShellExt.rc` | Risorse della DLL (Version Info) | +| `build.py` | Script Python per compilazione, firma e registrazione | + +## Configurazione + +La DLL legge la configurazione dal registro di Windows: + +### Chiavi di Registro + +**Percorso:** `HKEY_LOCAL_MACHINE\SOFTWARE\UniversalShellExt` + +*(Fallback: `HKEY_CURRENT_USER\SOFTWARE\UniversalShellExt`)* + +| Valore | Tipo | Descrizione | Esempio | +|--------|------|-------------|---------| +| `Command` | REG_SZ | Percorso completo dell'eseguibile | `N:\VSCode.exe` | +| `Arguments` | REG_SZ | Argomenti da passare (`%1` = path selezionato) | `"%1"` | +| `MenuText` | REG_SZ | Testo visualizzato nel menu | `Apri con VSCode` | +| `Icon` | REG_SZ | Percorso icona (exe o ico) | `N:\VSCode.exe` | + +### Template Configurazione + +```reg +Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\UniversalShellExt] +"Command"="N:\\VSCode.exe" +"Arguments"="\"%1\"" +"MenuText"="Apri con VSCode Portable" +"Icon"="N:\\VSCode.exe" +``` + +### Valori Default + +Se non configurati: +- `Arguments` → `"%1"` (passa il percorso selezionato) +- `MenuText` → `"Open with Application"` +- `Icon` → usa lo stesso percorso di `Command` + +## Identificatori + +| Tipo | Valore | +|------|--------| +| **CLSID** | `{5D849F86-F2A9-41FA-8A24-E01CD6D6696C}` | +| **Nome COM** | `Universal Portable Shell Extension` | + +## Compilazione + +### Prerequisiti +- **Visual Studio 2022** (con workload "Desktop development with C++") +- **Windows SDK** (per `signtool.exe` e `rc.exe`) +- **Python 3.x** +- **Certificato di firma** (opzionale, per firma digitale) + +### Build Script + +```powershell +# Build completo (compila, copia, firma, registra) +python build.py + +# Solo pulizia +python build.py --clean + +# Solo deregistrazione +python build.py --unregister + +# Build senza firma +python build.py --no-sign +``` + +### Output + +La DLL compilata viene copiata in: +``` +plugins\UniversalShellExt64.dll +``` + +## Registrazione + +### Metodo Consigliato (tramite Launcher) + +Il launcher `VSCode.exe` gestisce automaticamente la registrazione: + +```powershell +N:\VSCode.exe /register # Registra la shell extension +N:\VSCode.exe /unregister # Rimuove la shell extension +N:\VSCode.exe /register /silent # Modalità silenziosa +``` + +### Metodo Manuale (regsvr32) + +Eseguire come **Amministratore**: + +```cmd +:: Registrazione +regsvr32 "N:\Code\Workspace\Launchers\plugins\UniversalShellExt64.dll" + +:: Rimozione +regsvr32 /u "N:\Code\Workspace\Launchers\plugins\UniversalShellExt64.dll" +``` + +## Chiavi di Registro Create + +Durante la registrazione (`DllRegisterServer`): + +| Chiave | Scopo | +|--------|-------| +| `HKCR\CLSID\{5D849F86-...}` | Registrazione classe COM | +| `HKCR\CLSID\{5D849F86-...}\InprocServer32` | Percorso DLL | +| `HKCR\Directory\shellex\ContextMenuHandlers\UniversalShellExt` | Handler per cartelle | +| `HKCR\Directory\Background\shellex\ContextMenuHandlers\...` | Handler per sfondo | +| `HKLM\...\Shell Extensions\Approved` | Whitelist Microsoft | + +## Come Funziona + +1. **Inizializzazione**: Quando l'utente fa click destro, Explorer carica la DLL +2. **Configurazione**: La DLL legge `Command`, `Arguments`, `MenuText`, `Icon` dal registro +3. **Menu**: Inserisce la voce nel menu contestuale con l'icona configurata +4. **Esecuzione**: Al click, sostituisce `%1` con il percorso e lancia il comando +5. **UAC**: Se il comando richiede elevazione, la richiede automaticamente + +## Troubleshooting + +### La voce non appare nel menu +1. Verificare che la DLL sia registrata (`regsvr32`) +2. Controllare che le chiavi di registro siano configurate +3. Riavviare Explorer (`taskkill /F /IM explorer.exe && explorer.exe`) + +### Errore di registrazione +- Eseguire come Amministratore +- Verificare che la DLL sia firmata (Windows potrebbe bloccare DLL non firmate) + +### Il comando non viene eseguito +- Verificare il percorso in `Command` sia corretto +- Controllare che `Arguments` contenga `%1` per ricevere il percorso + +## Versioni + +| Versione | Note | +|----------|------| +| **1.0.0** | Prima release - Shell extension generica configurabile via registro | + +## Licenza + +Uso interno - emulab.it diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ccfc76c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Versioni Supportate + +Solo l'ultima versione minor riceve fix di security. + +| Versione | Supporto | +|----------|----------| +| Latest minor (es. 1.0.0) | ✅ | +| Versioni precedenti | ❌ | + +## Reporting di una Vulnerabilità + +**Non aprire issue pubbliche per vulnerabilità di security.** + +Per segnalare una vulnerabilità: + +1. **Preferito**: usa la funzione [Security Advisory privato](https://gitea.emulab.it/Simone/universal-shell-ext/security/advisories/new) di Gitea +2. **Alternativa**: invia email a `security@example.com` (sostituire con email reale prima del primo release) + +Includi: +- Descrizione della vulnerabilità +- Passi per riprodurla +- Versione/i affette +- Possibile mitigazione (se nota) + +## Tempo di Risposta + +- Acknowledgment iniziale: entro 7 giorni +- Valutazione: entro 14 giorni +- Patch (se confermata): tempistiche dipendono dalla severità + +## Hardening + +Questo plugin gira con i permessi del processo NSIS chiamante (tipicamente +l'utente che esegue l'installer). Non richiede privilegi elevati per la +compilazione né per l'esecuzione, salvo dove esplicitamente documentato. + +DLL distribuite tramite Releases sono compilate da CI GitHub Actions con +toolchain pinnata. Verificare hash SHA256 pubblicati nelle release. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/build.py b/build.py new file mode 100644 index 0000000..3070201 --- /dev/null +++ b/build.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Build script per UniversalShellExt64.dll +Compila, copia, firma e registra la shell extension generica. +Richiede Visual Studio 2022 e Windows SDK. +""" + +import subprocess +import shutil +import sys +import os +from pathlib import Path +import winreg + +# ============================================================================= +# Configurazione +# ============================================================================= + +# Percorsi +SCRIPT_DIR = Path(__file__).parent.resolve() +SOURCE_FILE = SCRIPT_DIR / "UniversalShellExt.cpp" +DEF_FILE = SCRIPT_DIR / "UniversalShellExt.def" +RC_FILE = SCRIPT_DIR / "UniversalShellExt.rc" +RES_FILE = SCRIPT_DIR / "UniversalShellExt.res" + +OUTPUT_DIR = SCRIPT_DIR / "bin" / "x64" +OUTPUT_DLL = OUTPUT_DIR / "UniversalShellExt64.dll" + +# Destinazione finale (cartella plugins) +DEST_DLL = SCRIPT_DIR.parent.parent / "plugins" / "UniversalShellExt64.dll" + +# Visual Studio +VS_PATH = Path("C:/Program Files/Microsoft Visual Studio/2022/Community") +VCVARS_BAT = VS_PATH / "VC/Auxiliary/Build/vcvars64.bat" + +# Signtool +SIGNTOOL = Path("C:/Program Files (x86)/Windows Kits/10/bin/10.0.22621.0/x64/signtool.exe") +CERT_THUMBPRINT = "DFF03ABA0E0156D8E7823A893F9588530A364410" +TIMESTAMP_URL = "http://timestamp.digicert.com" + +# Librerie +LIBS = [ + "ole32.lib", + "uuid.lib", + "shell32.lib", + "user32.lib", + "gdi32.lib", + "advapi32.lib", + "shlwapi.lib" +] + +CLSID_STRING = "{5D849F86-F2A9-41FA-8A24-E01CD6D6696C}" + +def run_command(cmd, shell=False, check=True): + print(f" > {cmd if isinstance(cmd, str) else ' '.join(cmd)}") + result = subprocess.run(cmd, shell=shell, capture_output=True, text=True) + if result.stdout: print(result.stdout) + if result.stderr: print(result.stderr) + if check and result.returncode != 0: + print(f"ERRORE: Comando fallito con codice {result.returncode}") + sys.exit(1) + return result + +def check_prerequisites(): + print("\n=== Verifica prerequisiti ===") + errors = [] + if not SOURCE_FILE.exists(): errors.append(f"Manca sorgente: {SOURCE_FILE}") + if not DEF_FILE.exists(): errors.append(f"Manca DEF: {DEF_FILE}") + if not VCVARS_BAT.exists(): errors.append(f"Manca VS: {VCVARS_BAT}") + if not SIGNTOOL.exists(): errors.append(f"Manca Signtool: {SIGNTOOL}") + if errors: + for e in errors: print(f" ERRORE: {e}") + sys.exit(1) + print(" Prerequisiti OK") + +def update_version(): + print("\n=== Incremento Versione ===") + import re + + if not RC_FILE.exists(): + print(" ERRORE: .rc non trovato!") + return + + content = RC_FILE.read_text(encoding="utf-8") + + # Cerca VER_FILEVERSION 1,0,0,X + # Regex per catturare i primi 3 numeri e l'ultimo + pattern = r'(#define VER_FILEVERSION\s+\d+,\d+,\d+,)(\d+)' + match = re.search(pattern, content) + + if match: + prefix = match.group(1) + build_num = int(match.group(2)) + new_build_num = build_num + 1 + + # Sostituisci VER_FILEVERSION + new_content = re.sub(pattern, f'{prefix}{new_build_num}', content) + + # Aggiorna anche VER_FILEVERSION_STR "1.0.0.X\0" + str_pattern = r'(#define VER_FILEVERSION_STR\s+"[^"]*?)(\d+)(\\0")' + # Nota: questa regex assume che la stringa finisca col numero da incrementare. + # Se è "1.0.0.0\0", cattura "1.0.0." come gruppo 1, "0" come 2. + + # Miglior approccio: ricostruire la stringa in base al nuovo numero + # Ma dobbiamo parsare i primi 3 numeri per essere sicuri + v_parts = prefix.strip().replace("#define VER_FILEVERSION", "").strip().split(',') + if len(v_parts) >= 3: + major, minor, patch = v_parts[0], v_parts[1], v_parts[2] + new_ver_str = f'{major}.{minor}.{patch}.{new_build_num}' + + # Sostituisci la stringa intera con gestione corretta di virgolette e backslash + # La regex deve catturare tutto fino ai doppi apici finali + new_content = re.sub(r'(#define VER_FILEVERSION_STR\s+)"(.*?)"', f'#define VER_FILEVERSION_STR "{new_ver_str}\\\\0"', new_content) + + # Aggiorna anche VER_PRODUCTVERSION (spesso uguale) + new_content = re.sub(r'(#define VER_PRODUCTVERSION\s+).*', f'#define VER_PRODUCTVERSION {major},{minor},{patch},{new_build_num}', new_content) + + # Aggiorna VER_PRODUCTVERSION_STR (spesso solo 3 cifre, ma qui mettiamo 4 per coerenza o lasciamo stare?) + # Nel file attuale è "1.0.0\0". Se vogliamo incrementare anche quello: + # new_content = re.sub(r'#define VER_PRODUCTVERSION_STR\s+".*?"', f'#define VER_PRODUCTVERSION_STR "{major}.{minor}.{patch}\\0"', new_content) + + RC_FILE.write_text(new_content, encoding="utf-8") + print(f" Versione aggiornata a: {new_ver_str}") + else: + print(" Non riesco a parsare la versione corrente.") + else: + print(" Pattern versione non trovato.") + +def clean_build(): + print("\n=== Clean build ===") + to_remove = [ + SCRIPT_DIR / "bin", + SCRIPT_DIR / "Release", + SCRIPT_DIR / "Debug", + SCRIPT_DIR / "x64", + SCRIPT_DIR / "UniversalShellExt64.dll", + SCRIPT_DIR / "UniversalShellExt64.exp", + SCRIPT_DIR / "UniversalShellExt64.lib", + SCRIPT_DIR / "UniversalShellExt64.pdb", + SCRIPT_DIR / "UniversalShellExt.obj", + RES_FILE, + ] + for path in to_remove: + if path.exists(): + if path.is_dir(): + try: shutil.rmtree(path) + except: pass + else: + try: path.unlink() + except: pass + print(" Clean completato") + +def kill_explorer(): + print("\n=== Termina Explorer ===") + subprocess.run(["taskkill", "/F", "/IM", "explorer.exe"], capture_output=True, check=False) + import time + time.sleep(2) + +def start_explorer(): + print("\n=== Riavvia Explorer ===") + subprocess.Popen(["explorer.exe"]) + import time + time.sleep(2) + +def unregister_dll(): + print("\n=== Deregistra DLL ===") + if DEST_DLL.exists(): + run_command(["regsvr32", "/u", "/s", str(DEST_DLL)], check=False) + print(" DLL deregistrata") + else: + print(" DLL non trovata, skip") + +def compile_dll(): + print("\n=== Compilazione DLL ===") + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # Compile Resource + rc_cmd = f'''call "{VCVARS_BAT}" && cd /D "{SCRIPT_DIR}" && rc.exe /fo"{RES_FILE.name}" "{RC_FILE.name}"''' + subprocess.run(f'cmd /c "{rc_cmd}"', shell=True, check=True) + + # Compile DLL + libs_str = " ".join(LIBS) + compile_cmd = f'''call "{VCVARS_BAT}" && cd /D "{SCRIPT_DIR}" && cl.exe /LD /EHsc /O2 /DUNICODE /D_UNICODE "{SOURCE_FILE.name}" "{RES_FILE.name}" /Fe:"{OUTPUT_DLL}" /link /DEF:"{DEF_FILE.name}" {libs_str}''' + + result = subprocess.run(f'cmd /c "{compile_cmd}"', shell=True, capture_output=True, text=True) + if result.stdout: + for line in result.stdout.split('\n'): + if line.strip() and not line.startswith('**') and not line.startswith('['): + print(f" {line}") + if result.returncode != 0 or not OUTPUT_DLL.exists(): + print("ERRORE: Compilazione fallita") + sys.exit(1) + + print(f" Compilata: {OUTPUT_DLL}") + +def copy_dll(): + print("\n=== Copia DLL ===") + shutil.copy2(OUTPUT_DLL, DEST_DLL) + print(f" Copiata in: {DEST_DLL}") + +def sign_dll(): + print("\n=== Firma DLL ===") + cmd = [str(SIGNTOOL), "sign", "/sha1", CERT_THUMBPRINT, "/fd", "SHA256", "/t", TIMESTAMP_URL, str(DEST_DLL)] + run_command(cmd) + print(" Firma OK") + +def register_dll(): + print("\n=== Registra DLL ===") + result = run_command(["regsvr32", "/s", str(DEST_DLL)], check=False) + if result.returncode == 0: + print(" Registrazione OK") + else: + print(f" FALLITA: {result.returncode} (Serve Admin?)") + +def verify_registration(): + print("\n=== Verifica Registrazione ===") + try: + key_path = fr"SOFTWARE\Classes\CLSID\{CLSID_STRING}\InprocServer32" + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key: + val, _ = winreg.QueryValueEx(key, "") + print(f" CLSID registrato: {val}") + except FileNotFoundError: + print(" ERRORE: CLSID non trovato") + return False + + try: + key_path = r"SOFTWARE\Classes\Directory\shellex\ContextMenuHandlers\UniversalShellExt" + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key: + val, _ = winreg.QueryValueEx(key, "") + print(f" Handler directory registrato: {val}") + except FileNotFoundError: + print(" ERRORE: Handler non trovato") + return False + return True + +def main(): + print("UniversalShellExt Builder") + args = sys.argv[1:] + + if "--clean" in args: + clean_build() + return + + if "--unregister" in args: + unregister_dll() + return + + # STEP 0: Clean pre-build + clean_build() + + update_version() + check_prerequisites() + unregister_dll() + kill_explorer() + compile_dll() + copy_dll() + + if "--no-sign" not in args: + sign_dll() + + start_explorer() + register_dll() + verify_registration() + clean_build() + + print("\nDONE!") + print(f"DLL: {DEST_DLL}") + print("Ricorda di configurare HKLM\\Software\\UniversalShellExt") + +if __name__ == "__main__": + main() diff --git a/dist/x64/UniversalShellExt64.dll b/dist/x64/UniversalShellExt64.dll new file mode 100644 index 0000000..948b836 Binary files /dev/null and b/dist/x64/UniversalShellExt64.dll differ diff --git a/src/UniversalShellExt.cpp b/src/UniversalShellExt.cpp new file mode 100644 index 0000000..a64505e --- /dev/null +++ b/src/UniversalShellExt.cpp @@ -0,0 +1,700 @@ +// UniversalShellExt.cpp - Generic Shell Extension +// Configurable via Registry +// Version: 1.1.0 + +#include +#include +#include +#include +#include + +#pragma comment(lib, "shlwapi.lib") + +// DLL Version +#define DLL_VERSION L"1.1.0" + +// GUID for UniversalShellExt +// {5D849F86-F2A9-41FA-8A24-E01CD6D6696C} +static const CLSID CLSID_UniversalShellExt = +{ 0x5d849f86, 0xf2a9, 0x41fa, { 0x8a, 0x24, 0xe0, 0x1c, 0xd6, 0xd6, 0x69, 0x6c } }; + +// CLSID as string +#define CLSID_STRING L"{5D849F86-F2A9-41FA-8A24-E01CD6D6696C}" + +// Registry Configuration - now per-CLSID +#define REG_KEY_BASE L"Software\\UniversalShellExt\\" +#define REG_VALUE_COMMAND L"Command" +#define REG_VALUE_ARGS L"Arguments" +#define REG_VALUE_ICON L"Icon" +#define REG_VALUE_TEXT L"MenuText" +#define REG_VALUE_ENABLE_LOG L"EnableLog" + +// Global Variables +HINSTANCE g_hInst = NULL; +LONG g_cDllRef = 0; + +// Internal Helper for File Logging +void WriteLogFile(const wchar_t* buffer) { + FILE* f; + if (_wfopen_s(&f, L"C:\\Users\\Simone\\.gemini\\shellext_debug.log", L"a") == 0) { + SYSTEMTIME st; + GetLocalTime(&st); + fwprintf(f, L"[%02d:%02d:%02d.%03d] %s\n", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, buffer); + fclose(f); + } +} + +// Global Logger (Legacy/Global scope) +void WriteLog(const wchar_t* format, ...) { + // Global logging is now MINIMAL or DISABLED by default unless needed. + // Uncomment for deep debugging of DLL loading. + // wchar_t buffer[1024]; + // va_list args; + // va_start(args, format); + // _vswprintf_p(buffer, 1024, format, args); + // va_end(args); + // WriteLogFile(buffer); +} + +// Configuration Cache globals removed. +// They are now member variables of the class. + +//============================================================================== +// Helper: Icon to Bitmap +//============================================================================== + +//============================================================================== +// Helper: Icon to Bitmap +//============================================================================== +HBITMAP IconToBitmap(HICON hIcon) +{ + if (!hIcon) return NULL; + ICONINFO iconInfo; + if (!GetIconInfo(hIcon, &iconInfo)) return NULL; + + BITMAP bm; + GetObject(iconInfo.hbmColor ? iconInfo.hbmColor : iconInfo.hbmMask, sizeof(bm), &bm); + + int width = bm.bmWidth; + int height = bm.bmHeight; + + if (iconInfo.hbmColor) DeleteObject(iconInfo.hbmColor); + if (iconInfo.hbmMask) DeleteObject(iconInfo.hbmMask); + + HDC hdcScreen = GetDC(NULL); + HDC hdcMem = CreateCompatibleDC(hdcScreen); + + BITMAPINFO bmi = {0}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + bmi.bmiHeader.biHeight = -height; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* pBits; + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &pBits, NULL, 0); + + if (hBitmap) + { + HBITMAP hOldBitmap = (HBITMAP)SelectObject(hdcMem, hBitmap); + RECT rc = {0, 0, width, height}; + FillRect(hdcMem, &rc, (HBRUSH)GetStockObject(BLACK_BRUSH)); // Transparent assumes 32bit alpha + DrawIconEx(hdcMem, 0, 0, hIcon, width, height, 0, NULL, DI_NORMAL); + SelectObject(hdcMem, hOldBitmap); + } + + DeleteDC(hdcMem); + ReleaseDC(NULL, hdcScreen); + return hBitmap; +} + +//============================================================================== +// Class UniversalShellExt +//============================================================================== +class UniversalShellExt : public IShellExtInit, public IContextMenu +{ +public: + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void **ppv) + { + static const QITAB qit[] = + { + QITABENT(UniversalShellExt, IContextMenu), + QITABENT(UniversalShellExt, IShellExtInit), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); + } + + IFACEMETHODIMP_(ULONG) AddRef() + { + return InterlockedIncrement(&m_cRef); + } + + IFACEMETHODIMP_(ULONG) Release() + { + ULONG cRef = InterlockedDecrement(&m_cRef); + if (cRef == 0) delete this; + return cRef; + } + + // IShellExtInit + IFACEMETHODIMP Initialize(LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDataObj, HKEY hKeyProgID) + { + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + Log(L"UniversalShellExt::Initialize called. CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + if (szCLSID) CoTaskMemFree(szCLSID); + + if (pDataObj == NULL) { + Log(L"Initialize - Error: pDataObj is NULL"); + return E_INVALIDARG; + } + + FORMATETC fe = { CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL }; + STGMEDIUM stm; + + if (SUCCEEDED(pDataObj->GetData(&fe, &stm))) + { + UINT nFiles = DragQueryFile((HDROP)stm.hGlobal, 0xFFFFFFFF, NULL, 0); + Log(L"Initialize - Files selected: %d", nFiles); + if (nFiles > 0) + { + DragQueryFile((HDROP)stm.hGlobal, 0, m_szSelectedPath, MAX_PATH); + Log(L"Initialize - Selected Path: %s", m_szSelectedPath); + } + ReleaseStgMedium(&stm); + } + else if (pidlFolder != NULL) + { + SHGetPathFromIDList(pidlFolder, m_szSelectedPath); + Log(L"Initialize - From PIDL: %s", m_szSelectedPath); + } + else { + Log(L"Initialize - Failed to get path data."); + } + return S_OK; + } + + // IContextMenu + IFACEMETHODIMP QueryContextMenu(HMENU hMenu, UINT indexMenu, UINT idCmdFirst, UINT idCmdLast, UINT uFlags) + { + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + Log(L"UniversalShellExt::QueryContextMenu called for CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + Log(L"QueryContextMenu - indexMenu: %d, idCmdFirst: %d, idCmdLast: %d, uFlags: 0x%X", indexMenu, idCmdFirst, idCmdLast, uFlags); + if (szCLSID) CoTaskMemFree(szCLSID); + + if (uFlags & CMF_DEFAULTONLY) return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + + LoadConfiguration(); // Load config into member variables + + // Fixed position at top + UINT targetPosition = 0; + + Log(L"QueryContextMenu - indexMenu: %d, idCmdFirst: %d, idCmdLast: %d", indexMenu, idCmdFirst, idCmdLast); + Log(L"QueryContextMenu - Using fixed position: %d", targetPosition); + Log(L"QueryContextMenu called. MenuText: %s", m_szMenuText); + + // Dont show if no command configured + if (m_szCommand[0] == L'\0') { + Log(L"QueryContextMenu - Error: Command is empty!"); + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + } + + HICON hIcon = NULL; + HBITMAP hBitmap = NULL; + + // Extract icon + if (m_szIcon[0] != L'\0') + { + Log(L"QueryContextMenu - Icon configured: %s", m_szIcon); + // Try to find index if comma exists e.g. "dll,1" + // Simple extraction for now + ExtractIconEx(m_szIcon, 0, NULL, &hIcon, 1); + } + + if (hIcon) + { + hBitmap = IconToBitmap(hIcon); + DestroyIcon(hIcon); + } + + if (m_hMenuBitmap) { DeleteObject(m_hMenuBitmap); m_hMenuBitmap = NULL; } + + MENUITEMINFO mii = { sizeof(mii) }; + mii.fMask = MIIM_STRING | MIIM_ID | MIIM_STATE | MIIM_BITMAP; + mii.wID = idCmdFirst; + mii.dwTypeData = m_szMenuText; + mii.fState = MFS_ENABLED; + mii.hbmpItem = hBitmap; + + if (!InsertMenuItem(hMenu, targetPosition, TRUE, &mii)) + { + if (hBitmap) DeleteObject(hBitmap); + Log(L"QueryContextMenu - InsertMenuItem FAILED. Error: %d", GetLastError()); + return HRESULT_FROM_WIN32(GetLastError()); + } + else + { + Log(L"QueryContextMenu - InsertMenuItem Success. ID: %d", idCmdFirst); + } + + m_hMenuBitmap = hBitmap; + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 1); + } + + IFACEMETHODIMP InvokeCommand(LPCMINVOKECOMMANDINFO pici) + { + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + Log(L"UniversalShellExt::InvokeCommand called for CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + +// Debug MessageBox removed for production + + + // Debug Box (Temporary - UNCOMMENTED FOR DEBUG) + // MessageBox(NULL, L"InvokeCommand Called!", L"Debug", MB_OK); + + bool isMatch = false; + + // 1. Check for Integer Offset (Standard Right-Click) + if (HIWORD(pici->lpVerb) == 0) + { + // We only add 1 item, so offset 0 is the only valid one. + if (LOWORD(pici->lpVerb) == 0) isMatch = true; + } + // 2. Check for String Verb (Programmatic/Windows 11) + else + { + LPCSTR pszVerb = pici->lpVerb; + // Compare with our CLSID string (which we return in GetCommandString) + // Need to convert our WCHAR CLSID to MultiByte for comparison + if (szCLSID) { + // Construct EXPECTED verb: Verb_XXXX (without braces) + WCHAR szVerbW[128]; + StringCchPrintf(szVerbW, 128, L"Verb_%s", szCLSID + 1); // Skip '{' + size_t lenW = 0; + StringCchLength(szVerbW, 128, &lenW); + if (lenW > 0 && szVerbW[lenW-1] == L'}') szVerbW[lenW-1] = L'\0'; + + // Convert to ANSI for comparison + int lenA = WideCharToMultiByte(CP_ACP, 0, szVerbW, -1, NULL, 0, NULL, NULL); + char* pszVerbA = new (std::nothrow) char[lenA]; + if (pszVerbA) { + WideCharToMultiByte(CP_ACP, 0, szVerbW, -1, pszVerbA, lenA, NULL, NULL); + + if (lstrcmpiA(pszVerb, pszVerbA) == 0) isMatch = true; + else if (lstrcmpiA(pszVerb, "UniversalShellExt") == 0) isMatch = true; // Fallback + + delete[] pszVerbA; + } + } + } + + if (szCLSID) CoTaskMemFree(szCLSID); + + if (!isMatch) { + Log(L"InvokeCommand - Verb mismatch. Rejecting."); + return E_INVALIDARG; + } + + // If command is empty, try reloading config (fallback) + if (m_szCommand[0] == L'\0') { + Log(L"InvokeCommand - Command empty, reloading..."); + LoadConfiguration(); + } + + // Check if command file exists + if (m_szCommand[0] == L'\0' || GetFileAttributes(m_szCommand) == INVALID_FILE_ATTRIBUTES) + { + WCHAR szMsg[MAX_PATH + 128]; + if (m_szCommand[0] == L'\0') + { + StringCchCopy(szMsg, ARRAYSIZE(szMsg), L"Nessun comando configurato.\n\nConfigurare la chiave di registro:\nHKLM\\SOFTWARE\\UniversalShellExt\\Command"); + } + else + { + StringCchPrintf(szMsg, ARRAYSIZE(szMsg), L"File non trovato:\n\n%s\n\nVerificare la configurazione nel registro.", m_szCommand); + } + Log(L"InvokeCommand - Error: %s", szMsg); + MessageBox(NULL, szMsg, L"UniversalShellExt - Errore", MB_OK | MB_ICONERROR); + return E_FAIL; + } + + // Prepare Parameters + // Replace %1 in m_szArguments with m_szSelectedPath (quoted) + WCHAR szArgsExpanded[MAX_PATH * 2]; + WCHAR szPathQuoted[MAX_PATH + 2]; + StringCchPrintf(szPathQuoted, MAX_PATH+2, L"\"%s\"", m_szSelectedPath); + + // Simple Replace of %1 + // Note: This is a basic implementation. + // It searches for %1 and replaces it. + + // Copy arguments to buffer + WCHAR *pFound = wcsstr(m_szArguments, L"%1"); + if (pFound) + { + size_t prefixLen = pFound - m_szArguments; + wcsncpy_s(szArgsExpanded, ARRAYSIZE(szArgsExpanded), m_szArguments, prefixLen); + szArgsExpanded[prefixLen] = L'\0'; + StringCchCat(szArgsExpanded, ARRAYSIZE(szArgsExpanded), szPathQuoted); // Insert Path (quoted) + StringCchCat(szArgsExpanded, ARRAYSIZE(szArgsExpanded), pFound + 2); + } + else + { + // No %1 found, just use arguments as is. + // The provided snippet had a different logic here, appending the path if no %1. + // Sticking to the original logic for now, which means if no %1, path is not appended. + StringCchCopy(szArgsExpanded, ARRAYSIZE(szArgsExpanded), m_szArguments); + } + + SHELLEXECUTEINFO sei = { sizeof(sei) }; + sei.fMask = SEE_MASK_NOCLOSEPROCESS; + sei.lpVerb = L"open"; + sei.lpFile = m_szCommand; + sei.lpParameters = szArgsExpanded; + sei.nShow = SW_SHOWNORMAL; + + // Set Working Directory to the folder containing the executable + WCHAR szDir[MAX_PATH]; + StringCchCopy(szDir, MAX_PATH, m_szCommand); + PathRemoveFileSpec(szDir); // Requires shlwapi.h and linking shlwapi.lib + sei.lpDirectory = szDir; + + Log(L"InvokeCommand - Executing: %s Params: %s Dir: %s", sei.lpFile, sei.lpParameters, sei.lpDirectory); + + if (!ShellExecuteEx(&sei)) + { + DWORD lastError = GetLastError(); + Log(L"InvokeCommand - ShellExecuteEx failed with error: %d", lastError); + if (lastError == ERROR_ELEVATION_REQUIRED) + { + sei.lpVerb = L"runas"; + Log(L"InvokeCommand - Retrying with 'runas' verb."); + ShellExecuteEx(&sei); + } else { + MessageBox(NULL, L"Failed to launch command.", L"Error", MB_ICONERROR); + return E_FAIL; + } + } + + if (sei.hProcess) CloseHandle(sei.hProcess); + Log(L"InvokeCommand - Success"); + return S_OK; + } + + IFACEMETHODIMP GetCommandString(UINT_PTR idCmd, UINT uType, UINT *pReserved, CHAR *pszName, UINT cchMax) + { + Log(L"GetCommandString called. uType: %d", uType); + if (idCmd != 0) return E_INVALIDARG; + + LoadConfiguration(); // Reload config + + switch (uType) + { + case GCS_HELPTEXTW: + StringCchCopyW((LPWSTR)pszName, cchMax, m_szMenuText); + return S_OK; + + case GCS_HELPTEXTA: + { + int len = WideCharToMultiByte(CP_ACP, 0, m_szMenuText, -1, NULL, 0, NULL, NULL); + if (len <= cchMax) WideCharToMultiByte(CP_ACP, 0, m_szMenuText, -1, pszName, cchMax, NULL, NULL); + } + return S_OK; + + case GCS_VERBW: + case GCS_VALIDATEW: + { + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + if (szCLSID) + { + // Sanitize Verb: replace {CLSID} with Verb_CLSID (no braces) + WCHAR szVerb[128]; + StringCchPrintf(szVerb, 128, L"Verb_%s", szCLSID + 1); // Skip '{' + size_t len = 0; + StringCchLength(szVerb, 128, &len); + if (len > 0 && szVerb[len-1] == L'}') szVerb[len-1] = L'\0'; + + Log(L"GetCommandString - Returning VERB: %s", szVerb); + + if (uType == GCS_VERBW && pszName) { + StringCchCopyW((LPWSTR)pszName, cchMax, szVerb); + } + CoTaskMemFree(szCLSID); + return S_OK; + } + return E_FAIL; + } + + case GCS_VERBA: + case GCS_VALIDATEA: + // Return "UniversalShellExt" for legacy ANSI, but maybe we should return Verb_GUID here too if possible? + // Sticking to simplified "Valid" response for now to minimize charset risk. + // If Verb is requested (ANSI), we return the generic string. + if (uType == GCS_VERBA && pszName) { + StringCchCopyA(pszName, cchMax, "UniversalShellExt"); + } + return S_OK; + + default: + Log(L"GetCommandString - Unknown uType: %d", uType); + return E_INVALIDARG; + } + } + + UniversalShellExt(REFCLSID clsid) : m_cRef(1), m_hMenuBitmap(NULL), m_clsid(clsid) + { + InterlockedIncrement(&g_cDllRef); + + // Stringify CLSID for log + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + Log(L"UniversalShellExt::Constructor - CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + if (szCLSID) CoTaskMemFree(szCLSID); + + m_szSelectedPath[0] = L'\0'; + m_szCommand[0] = L'\0'; + m_szArguments[0] = L'\0'; + m_szIcon[0] = L'\0'; + m_szMenuText[0] = L'\0'; + m_bEnableLog = FALSE; + } + +protected: + ~UniversalShellExt() + { + LPOLESTR szCLSID = NULL; + StringFromCLSID(m_clsid, &szCLSID); + Log(L"UniversalShellExt::Destructor - CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + if (szCLSID) CoTaskMemFree(szCLSID); + + if (m_hMenuBitmap) DeleteObject(m_hMenuBitmap); + InterlockedDecrement(&g_cDllRef); + } + +private: + LONG m_cRef; + WCHAR m_szSelectedPath[MAX_PATH]; + HBITMAP m_hMenuBitmap; + CLSID m_clsid; + // Member variables to store configuration for this instance + WCHAR m_szCommand[MAX_PATH]; + WCHAR m_szArguments[MAX_PATH]; + WCHAR m_szIcon[MAX_PATH]; + WCHAR m_szMenuText[128]; + BOOL m_bEnableLog; + + // Member helper for logging + void Log(const wchar_t* format, ...) { + if (!m_bEnableLog) return; + + wchar_t buffer[1024]; + va_list args; + va_start(args, format); + _vswprintf_p(buffer, 1024, format, args); + va_end(args); + + WriteLogFile(buffer); + } + + // Internal LoadConfiguration method + void LoadConfiguration() + { + m_szCommand[0] = L'\0'; + m_szArguments[0] = L'\0'; + m_szIcon[0] = L'\0'; + m_szMenuText[0] = L'\0'; + + // Build registry path: Software\UniversalShellExt\{CLSID} + WCHAR szKeyPath[256]; + WCHAR szClsid[64]; + StringFromGUID2(m_clsid, szClsid, ARRAYSIZE(szClsid)); + StringCchPrintf(szKeyPath, ARRAYSIZE(szKeyPath), L"%s%s", REG_KEY_BASE, szClsid); + + HKEY hKey; + DWORD dwSize; + DWORD dwType = REG_SZ; + + // Try to open key from HKLM (64-bit view) + LSTATUS status = RegOpenKeyEx(HKEY_LOCAL_MACHINE, szKeyPath, 0, KEY_READ | KEY_WOW64_64KEY, &hKey); + if (status != ERROR_SUCCESS) + { + status = RegOpenKeyEx(HKEY_CURRENT_USER, szKeyPath, 0, KEY_READ, &hKey); + } + + if (status == ERROR_SUCCESS) + { + // Read Command + dwSize = sizeof(m_szCommand); + RegQueryValueEx(hKey, REG_VALUE_COMMAND, NULL, &dwType, (LPBYTE)m_szCommand, &dwSize); + + // Read Arguments + dwSize = sizeof(m_szArguments); + RegQueryValueEx(hKey, REG_VALUE_ARGS, NULL, &dwType, (LPBYTE)m_szArguments, &dwSize); + + // Read Icon + dwSize = sizeof(m_szIcon); + RegQueryValueEx(hKey, REG_VALUE_ICON, NULL, &dwType, (LPBYTE)m_szIcon, &dwSize); + + // Read MenuText + dwSize = sizeof(m_szMenuText); + dwSize = sizeof(m_szMenuText); + RegQueryValueEx(hKey, REG_VALUE_TEXT, NULL, &dwType, (LPBYTE)m_szMenuText, &dwSize); + + // Read EnableLog + DWORD dwLog = 0; + dwSize = sizeof(dwLog); + DWORD dwTypeDw = REG_DWORD; + if (RegQueryValueEx(hKey, REG_VALUE_ENABLE_LOG, NULL, &dwTypeDw, (LPBYTE)&dwLog, &dwSize) == ERROR_SUCCESS) { + m_bEnableLog = (dwLog != 0); + } + + RegCloseKey(hKey); + } + + // Defaults + if (m_szCommand[0] == L'\0') { + // Nothing to run? + } + + if (m_szArguments[0] == L'\0') { + StringCchCopy(m_szArguments, MAX_PATH, L"\"%1\""); + } + + if (m_szIcon[0] == L'\0') { + // Default icon to command exe + StringCchCopy(m_szIcon, MAX_PATH, m_szCommand); + } + + if (m_szMenuText[0] == L'\0') { + StringCchCopy(m_szMenuText, 128, L"Open with Application"); + } + } +}; + +//============================================================================== +// Class Factory +//============================================================================== +class ClassFactory : public IClassFactory +{ +public: + IFACEMETHODIMP QueryInterface(REFIID riid, void **ppv) + { + static const QITAB qit[] = { QITABENT(ClassFactory, IClassFactory), { 0 } }; + return QISearch(this, qit, riid, ppv); + } + IFACEMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&m_cRef); } + IFACEMETHODIMP_(ULONG) Release() + { + ULONG cRef = InterlockedDecrement(&m_cRef); + if (cRef == 0) delete this; + return cRef; + } + IFACEMETHODIMP CreateInstance(IUnknown *pUnkOuter, REFIID riid, void **ppv) + { + if (pUnkOuter != NULL) return CLASS_E_NOAGGREGATION; + UniversalShellExt *pExt = new (std::nothrow) UniversalShellExt(m_clsid); + if (pExt == NULL) return E_OUTOFMEMORY; + HRESULT hr = pExt->QueryInterface(riid, ppv); + pExt->Release(); + return hr; + } + IFACEMETHODIMP LockServer(BOOL fLock) + { + if (fLock) InterlockedIncrement(&g_cDllRef); + else InterlockedDecrement(&g_cDllRef); + return S_OK; + } + ClassFactory(const CLSID& clsid) : m_cRef(1), m_clsid(clsid) { } +protected: + ~ClassFactory() { } +private: + LONG m_cRef; + CLSID m_clsid; +}; + +//============================================================================== +// DLL Exports +//============================================================================== + +STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void **ppv) +{ + LPOLESTR szCLSID = NULL; + StringFromCLSID(rclsid, &szCLSID); + WriteLog(L"DllGetClassObject - Requesting CLSID: %s", szCLSID ? szCLSID : L"Unknown"); + if (szCLSID) CoTaskMemFree(szCLSID); + + *ppv = NULL; + if (!IsEqualIID(riid, IID_IClassFactory)) + return CLASS_E_CLASSNOTAVAILABLE; + + ClassFactory* pFactory = new (std::nothrow) ClassFactory(rclsid); // Pass CLSID to factory + if (!pFactory) + return E_OUTOFMEMORY; + + HRESULT hr = pFactory->QueryInterface(riid, ppv); + pFactory->Release(); + return hr; +} + +STDAPI DllCanUnloadNow(void) { return g_cDllRef > 0 ? S_FALSE : S_OK; } + +HRESULT SetRegistryValue(HKEY hKeyRoot, LPCWSTR pszSubKey, LPCWSTR pszValueName, LPCWSTR pszValue) +{ + HKEY hKey; + HRESULT hr = HRESULT_FROM_WIN32(RegCreateKeyEx(hKeyRoot, pszSubKey, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL, &hKey, NULL)); + if (SUCCEEDED(hr)) + { + if (pszValue != NULL) + { + DWORD cbData = (lstrlen(pszValue) + 1) * sizeof(WCHAR); + hr = HRESULT_FROM_WIN32(RegSetValueEx(hKey, pszValueName, 0, REG_SZ, (const BYTE*)pszValue, cbData)); + } + RegCloseKey(hKey); + } + return hr; +} + +STDAPI DllRegisterServer(void) +{ + WCHAR szModulePath[MAX_PATH]; + if (!GetModuleFileName(g_hInst, szModulePath, ARRAYSIZE(szModulePath))) return HRESULT_FROM_WIN32(GetLastError()); + + HRESULT hr = S_OK; + hr = SetRegistryValue(HKEY_CLASSES_ROOT, L"CLSID\\" CLSID_STRING, NULL, L"Universal Portable Shell Extension"); + if (FAILED(hr)) return hr; + hr = SetRegistryValue(HKEY_CLASSES_ROOT, L"CLSID\\" CLSID_STRING L"\\InprocServer32", NULL, szModulePath); + if (FAILED(hr)) return hr; + hr = SetRegistryValue(HKEY_CLASSES_ROOT, L"CLSID\\" CLSID_STRING L"\\InprocServer32", L"ThreadingModel", L"Apartment"); + if (FAILED(hr)) return hr; + + // Register Context Menu Handler + hr = SetRegistryValue(HKEY_CLASSES_ROOT, L"Directory\\shellex\\ContextMenuHandlers\\UniversalShellExt", NULL, CLSID_STRING); + if (FAILED(hr)) return hr; + hr = SetRegistryValue(HKEY_CLASSES_ROOT, L"Directory\\Background\\shellex\\ContextMenuHandlers\\UniversalShellExt", NULL, CLSID_STRING); + if (FAILED(hr)) return hr; + + // Approve + hr = SetRegistryValue(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved", CLSID_STRING, L"Universal Shell Extension"); + return hr; +} + +STDAPI DllUnregisterServer(void) +{ + RegDeleteTree(HKEY_CLASSES_ROOT, L"CLSID\\" CLSID_STRING); + RegDeleteTree(HKEY_CLASSES_ROOT, L"Directory\\shellex\\ContextMenuHandlers\\UniversalShellExt"); + RegDeleteTree(HKEY_CLASSES_ROOT, L"Directory\\Background\\shellex\\ContextMenuHandlers\\UniversalShellExt"); + + HKEY hKey; + if (RegOpenKeyEx(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved", 0, KEY_WRITE, &hKey) == ERROR_SUCCESS) + { + RegDeleteValue(hKey, CLSID_STRING); + RegCloseKey(hKey); + } + return S_OK; +} diff --git a/src/UniversalShellExt.def b/src/UniversalShellExt.def new file mode 100644 index 0000000..40a06be --- /dev/null +++ b/src/UniversalShellExt.def @@ -0,0 +1,6 @@ +LIBRARY UniversalShellExt +EXPORTS + DllGetClassObject PRIVATE + DllCanUnloadNow PRIVATE + DllRegisterServer PRIVATE + DllUnregisterServer PRIVATE diff --git a/src/UniversalShellExt.rc b/src/UniversalShellExt.rc new file mode 100644 index 0000000..fcb872b --- /dev/null +++ b/src/UniversalShellExt.rc @@ -0,0 +1,44 @@ +// VSCodeShellExt.rc - Version Information Resource + +#include + +#define VER_FILEVERSION 1,0,0,6 +#define VER_FILEVERSION_STR "1.0.0.6\0" +#define VER_PRODUCTVERSION 1,0,0,6 +#define VER_PRODUCTVERSION_STR "1.0.0\0" + +#define VER_COMPANYNAME_STR "Generic Shell Ext\0" +#define VER_FILEDESCRIPTION_STR "Universal Portable Shell Extension\0" +#define VER_INTERNALNAME_STR "UniversalShellExt\0" +#define VER_LEGALCOPYRIGHT_STR "Copyright (C) 2024\0" +#define VER_ORIGINALFILENAME_STR "UniversalShellExt64.dll\0" +#define VER_PRODUCTNAME_STR "Universal Portable Shell Extension\0" + +VS_VERSION_INFO VERSIONINFO +FILEVERSION VER_FILEVERSION +PRODUCTVERSION VER_PRODUCTVERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +FILEFLAGS 0 +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_DLL +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "CompanyName", VER_COMPANYNAME_STR + VALUE "FileDescription", VER_FILEDESCRIPTION_STR + VALUE "FileVersion", VER_FILEVERSION_STR + VALUE "InternalName", VER_INTERNALNAME_STR + VALUE "LegalCopyright", VER_LEGALCOPYRIGHT_STR + VALUE "OriginalFilename", VER_ORIGINALFILENAME_STR + VALUE "ProductName", VER_PRODUCTNAME_STR + VALUE "ProductVersion", VER_PRODUCTVERSION_STR + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/src/UniversalShellExt.vcxproj b/src/UniversalShellExt.vcxproj new file mode 100644 index 0000000..900f8bd --- /dev/null +++ b/src/UniversalShellExt.vcxproj @@ -0,0 +1,89 @@ + + + + + Release + Win32 + + + Release + x64 + + + + 17.0 + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + VSCodeShellExt + 10.0 + + + + DynamicLibrary + false + v143 + true + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + $(SolutionDir)bin\x86\ + $(Configuration)\x86\ + VSCodeShellExt32 + + + $(SolutionDir)bin\x64\ + $(Configuration)\x64\ + UniversalShellExt64 + + + + Level3 + true + true + true + WIN32;NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + + + Windows + true + true + UniversalShellExt.def + + + + + Level3 + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + + + Windows + true + true + UniversalShellExt.def + + + + + + + + + +