Developer Guide
Contents
- Prerequisites
- Building from Source
- Project Structure
- Versioning System
- Adding a New Setting
- Adding a New Menu Item
- Adding a New Dialog
- Adding a New Script Source
- Filter and Sort System
- CI/CD Workflow
- Releasing a New Version
- Thread Safety
- Code Style
Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| LLVM | 17+ | C compiler (Clang) |
| CMake | 3.16+ | Build system |
| Ninja | any | Build backend |
| Visual Studio | 2019+ | Windows SDK and linker (rc.exe, link.exe) |
| Qt Creator | any | IDE (optional) |
| Git | any | Version control |
Note: Visual Studio (or Build Tools for Visual Studio) is required to provide
rc.exe(resource compiler) and the Windows SDK headers. LLVM/Clang is the C compiler; Visual Studio provides the rest of the Windows toolchain.
Building from Source
Command Line
Open a Developer Command Prompt for VS (or run ilammy/msvc-dev-cmd equivalent), then:
git clone https://github.com/KaiUR/CatiaMenuWin32
cd CatiaMenuWin32
cmake -S . -B build -G "Ninja" -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl
cmake --build build
The executable is output to build/CatiaMenuWin32.exe.
Note:
res/app_icon.icomust exist before building. It is included in the repository.
Qt Creator
- Open
CMakeLists.txtin Qt Creator - Select a Clang kit (configured against the MSVC toolchain)
- Add
-DCMAKE_C_COMPILER=clang-clto the CMake arguments - Configure the project
- Build → Build All (
Ctrl+B)
CMake Variables
| Variable | Default | Description |
|---|---|---|
VERSION_OVERRIDE |
unset | Override version (used by CI workflow) |
CMAKE_BUILD_TYPE |
Debug | Release strips debug symbols |
When VERSION_OVERRIDE is not set, CMake reads the latest git tag via git tag --sort=-creatordate and uses it as the base version. The build number is always incremented from build_number.txt.
Project Structure
CatiaMenuWin32/
├── src/ Source files
│ ├── main.h Central header — all structs, types, prototypes
│ ├── main.c Entry point, WinMain, MainWndProc, message handling
│ ├── resource.h All #define IDs for controls, menus, dialogs
│ ├── window.c Window creation, tab bar, tray, menu popup
│ ├── tabs.c Tab switching, script button creation, scroll panel
│ ├── paint.c All GDI rendering — toolbar, buttons, tooltip
│ ├── github.c HTTPS requests, cert validation, SHA computation
│ ├── sync.c GitHub sync thread, local dir scanning, manifest
│ ├── runner.c Script execution, Python detection, Update Deps
│ ├── meta.c Script header parser (Purpose, Author, etc.)
│ ├── help.c In-app help window (TreeView + RichEdit)
│ ├── prefs.c Favourites, hidden scripts, run counts, notes
│ ├── sources.c Sources dialog — extra repos and local folders
│ ├── settings.c Settings load/save, Settings dialog, About dialog
│ ├── updater.c Update checker — GitHub releases API
│ └── quickbar.c Floating Quick Launch Bar
├── res/
│ ├── resource.rc.in Resource script template (CMake substitutes version)
│ ├── version.h.in Version header template
│ ├── app.manifest Application manifest (DPI awareness, ComCtl32 v6)
│ └── app_icon.ico Application icon
├── docs/ Documentation
├── .github/
│ └── workflows/
│ └── release.yml GitHub Actions CI/CD workflow
├── CMakeLists.txt Build configuration
├── build_number.txt Auto-incremented build counter
├── README.md Project overview
├── LICENSE.txt MIT license
└── CONTRIBUTORS.md Auto-generated from git history
Versioning System
Version format: major.minor.patch.build — e.g. v1.2.0.31
- major.minor.patch — set by the git tag you push (e.g.
v1.2.0) - build — auto-incremented by CMake from
build_number.txton every configure
Local builds
CMake reads the latest tag via git tag --sort=-creatordate, extracts major.minor.patch, increments build_number.txt, and generates version.h. The resulting binary shows e.g. v1.2.0.32.
CI builds
The workflow passes -DVERSION_OVERRIDE=1.2.0 to CMake (extracted from your tag), CMake increments build_number.txt, the workflow commits the new number back to main, then creates the final tag v1.2.0.31 matching the binary exactly.
version.h (generated)
#define VERSION_MAJOR 1
#define VERSION_MINOR 2
#define VERSION_PATCH 0
#define VERSION_BUILD 31
#define IS_LOCAL_BUILD 1 /* 0 in CI builds */
#define VERSION_STRING_W L"1.2.0.31"
#define VERSION_DISPLAY_W L"1.2.0.31"
Adding a New Setting
main.h— add field to theSettingsstructsettings.c— addGetPrivateProfileInt/StringinSettings_LoadandWritePrivateProfile*inSettings_Saveres/resource.rc.in— add control to the Settings dialog (IDD_SETTINGS = 300)src/resource.h— add#define IDC_*for the new controlsettings.cSettingsDlgProc— handle inWM_INITDIALOG(populate) andIDOK(read back)- Use the setting wherever needed in the relevant
.cfile
Adding a New Menu Item
The hamburger menu is built entirely in Window_ShowMenu (window.c) and commands are dispatched in Handle_Command (main.c).
src/resource.h— add a#define IDM_MY_ACTION <value>(use the next available number in theIDM_*range)window.cWindow_ShowMenu— addAppendMenu(hSubMenu, MF_STRING, IDM_MY_ACTION, L"My Action")to the appropriate sub-menumain.cHandle_Command— add acase IDM_MY_ACTION:branch; keep it short — call a dedicated function if the logic is non-trivial- If the item should show a checkmark, pass the current state to
AppendMenuwithMF_CHECKED/MF_UNCHECKEDflags, and toggleg.cfg.*+ callSettings_Savein the handler
Tray menu: if the item should also appear in the system tray right-click menu, add it to
Window_ShowTrayMenuin the same way.
Adding a New Dialog
All modal dialogs are defined in res/resource.rc.in and handled by an INT_PTR CALLBACK dialog proc in the appropriate .c file.
src/resource.h— add#define IDD_MY_DIALOG <value>and anyIDC_*control IDsres/resource.rc.in— add theIDD_MY_DIALOG DIALOGEXblock with controls- Appropriate
.cfile — implementMyDlgProc(HWND, UINT, WPARAM, LPARAM)handling at minimumWM_INITDIALOG(populate) andIDOK/IDCANCEL(read back / dismiss) main.h— declareINT_PTR CALLBACK MyDlgProc(HWND, UINT, WPARAM, LPARAM);- Caller — open with
DialogBox(GetModuleHandle(NULL), MAKEINTRESOURCE(IDD_MY_DIALOG), g.hwnd, MyDlgProc)
Theme-aware dialogs: to match the app theme, handle WM_ERASEBKGND and return a brush of COL_BG(), and call Window_ApplyDarkMode(hwnd) and Window_ApplyThemeToChildren(hwnd) in WM_INITDIALOG.
Filter and Sort System
Filter
The search box (hwnd_search, IDC_SEARCH) posts EN_CHANGE notifications to MainWndProc. The handler copies the edit text to g.filter_text and calls Tabs_ApplyFilter().
Tabs_ApplyFilter → Tabs_RebuildButtons — which calls Tabs_ScriptMatchesFilter for every non-hidden script. A script is shown only when the filter is empty or its name / meta.purpose contains g.filter_text (case-insensitive substring match via wcsstr).
Clearing the search box sets g.filter_text[0] = L'\0' and rebuilds — all non-hidden scripts reappear.
Sort
SortMode values are stored in g.cfg.sort_mode (global default, settable in Settings) and in each ScriptFolder.sort_mode (per-tab override, not yet exposed in UI).
Tabs_ApplySort(fi) is called after every sync and after each manual sort change. It calls qsort on g.folders[fi].scripts[] with the matching comparator:
SortMode |
Comparator | Notes |
|---|---|---|
SORT_ORDER |
(none) | GitHub API / disk order — qsort not called |
SORT_ALPHA |
_wcsicmp(a->name, b->name) |
Case-insensitive A-Z |
SORT_DATE |
wcscmp(b->date, a->date) |
Descending: newest first |
SORT_MOST_USED |
b->run_count - a->run_count |
Descending: most runs first |
After Tabs_ApplySort, call Tabs_RebuildButtons (or Tabs_Switch) to reflect the new order in the UI.
Adding a New Script Source Type
Script sources are synced in sync.c:
Sync_Thread— main GitHub repo sync (steps 1–5)Sync_ExtraRepo— extra GitHub repos (step 6)Sync_LocalDir— local folders (step 7)Sync_MergeFolder— merges scripts intog.folders[]by folder name
To add a new source type:
- Add fields to
Settingsinmain.h - Add load/save in
settings.c - Add a UI in
sources.c - Add a sync function in
sync.cfollowing the pattern ofSync_ExtraRepo - Call it from
Sync_ThreadandSync_LoadManifest
CI/CD Workflow
The workflow (.github/workflows/release.yml) runs on windows-latest using LLVM/Clang as the C compiler with the Visual Studio MSVC toolchain providing the Windows SDK and resource compiler.
It triggers on:
- Push to
mainbranch — builds only, no release - Push of a
v*tag — full build, code signing, version tagging, and release
Tag release flow
- Checkout with full history
- Set up MSVC environment (
ilammy/msvc-dev-cmd) and add LLVM to PATH - Update
CONTRIBUTORS.mdfrom git log - Extract
major.minor.patchfrom the pushed tag - Run
cmake -DCMAKE_C_COMPILER=clang-cl -DVERSION_OVERRIDE=major.minor.patch(incrementsbuild_number.txt) - Build with Ninja
- Code-sign
CatiaMenuWin32.exeviaskymatic/code-sign-action@v1using the certificate stored in GitHub Secrets - Commit
build_number.txtandCONTRIBUTORS.mdback tomain - Import GPG key and sign the new tag — deletes the base tag (e.g.
v1.2.0), creates a GPG-signed tag with the full build number (e.g.v1.2.0.31) - Create GitHub Release with the signed
CatiaMenuWin32.exe,README.md,CONTRIBUTORS.md,LICENSE.txt
Code signing secrets
The following secrets must be configured in the repository (Settings → Secrets → Actions):
| Secret | Description |
|---|---|
CERTIFICATE |
Base64-encoded PFX file. Generate with: [System.Convert]::ToBase64String([IO.File]::ReadAllBytes('cert.pfx')) |
PASSWORD |
Password protecting the PFX certificate |
CERTHASH |
SHA1 thumbprint of the certificate (from MMC → Certificates) |
CERTNAME |
Common name of the certificate |
GPG signing secrets
The following secrets are used by crazy-max/ghaction-import-gpg@v6 to sign release tags:
| Secret | Description |
|---|---|
GPG_PRIVATE_KEY |
ASCII-armored GPG private key. Export with: gpg --armor --export-secret-keys KEY_ID |
GPG_PASSPHRASE |
Passphrase protecting the GPG private key |
The imported key is configured as the git signing key; every release tag is created with git tag -s, producing a verified tag on GitHub. The committer identity baked into the key must match the git_committer_name / git_committer_email values in the workflow (KaiUR / kairathjen@yahoo.com).
Releasing a New Version
git tag v1.3.0
git push origin v1.3.0
That’s it. The workflow handles everything else automatically. The final release tag will be v1.3.0.<buildnum>.
Increment rules:
- Patch (
v1.2.1) — bug fixes - Minor (
v1.3.0) — new features, backward compatible - Major (
v2.0.0) — breaking changes or major redesign
Thread Safety
The app uses two background threads (sync and updater) plus the UI thread. Thread-safe communication follows two patterns:
Posting to the UI thread
Never call Win32 UI functions from a background thread. Use PostMessage to marshal work to the UI thread:
// From any thread — allocate a buffer, format, post:
PostStatus(L"Sync done."); // inline helper in main.h
// Custom messages (defined in main.h):
PostMessage(g.hwnd, WM_SYNC_DONE, (WPARAM)result, 0);
PostMessage(g.hwnd, WM_SCRIPT_STARTED, 0, 0);
PostMessage(g.hwnd, WM_SCRIPT_STOPPED, 0, 0);
WM_STATUS_SET frees the heap buffer after displaying it. All other custom messages use simple wParam/lParam values.
Protecting shared state
g.folders[] and g.folder_count are written by the sync thread and read by the UI thread. Guard every access with g.cs_folders:
EnterCriticalSection(&g.cs_folders);
// read or write g.folders[] here
LeaveCriticalSection(&g.cs_folders);
Atomic handle ownership (g.run_process)
The running-script process handle is shared between Runner_Thread (writer) and Runner_Stop / MainWndProc (readers). Use InterlockedExchangePointer for ownership transfer — exactly one caller gets the non-NULL handle:
// Runner_Thread — store a duplicate after CreateProcess:
DuplicateHandle(..., pi.hProcess, ..., &dup, ...);
InterlockedExchangePointer((void **)&g.run_process, dup);
// Runner_Stop — atomically claim it:
HANDLE h = (HANDLE)InterlockedExchangePointer((void **)&g.run_process, NULL);
if (h) { TerminateProcess(h, 1); CloseHandle(h); }
// Runner_Thread cleanup — take back if Stop hasn't claimed it:
HANDLE old = (HANDLE)InterlockedExchangePointer((void **)&g.run_process, NULL);
if (old) CloseHandle(old); // normal completion path
This pattern guarantees no double-close and no double-terminate regardless of which side wins the race.
Repeat-on-Double-Click Architecture
The repeat feature re-runs a script automatically each time it completes. State is stored in AppState g (never in dialogs or local statics):
| Field | Type | Purpose |
|---|---|---|
g.repeat_mode |
bool |
true while a script is looping |
g.repeat_fi |
int |
Folder index of the script to repeat |
g.repeat_si |
int |
Script index within that folder |
g.suppress_lbuttonup |
bool |
Suppresses the extra WM_LBUTTONUP that follows a WM_LBUTTONDBLCLK |
Message sequence (main window)
Win32 double-click sends: WM_LBUTTONDOWN → WM_LBUTTONUP (first click, runs the script via WM_COMMAND) → WM_LBUTTONDBLCLK → WM_LBUTTONUP (must be suppressed).
WM_LBUTTONDBLCLK is handled in BtnSubclassProc (paint.c): it sets g.repeat_mode, g.repeat_fi/si, and g.suppress_lbuttonup = true. The next WM_LBUTTONUP checks suppress_lbuttonup, clears it, and returns without running the script.
Repeat trigger
WM_SCRIPT_STOPPED (main.c → MainWndProc) checks g.repeat_mode and calls Runner_Run(g.repeat_fi, g.repeat_si) to start the next iteration.
Cancellation
- Escape (
WM_KEYDOWNinMainWndProcandQuickBarProc): clearsg.repeat_mode, repaints the button, and callsRunner_Stop()to terminate the running script. - Single-click same script (
Handle_Command): clearsg.repeat_mode, skips the run. - Single-click different script (
Handle_Command): clearsg.repeat_mode, runs the new script. - Stop button (
IDC_BTN_STOPinHandle_Command): clearsg.repeat_mode, then callsRunner_Stop().
Quick Bar
QuickBarProc (quickbar.c) receives WM_LBUTTONDBLCLK because CS_DBLCLKS is set on the bar window class. The handler mirrors the main-window logic using QB_GetFav to resolve the hit index to fi/si.
Visual indicator
Paint_ScriptButton (paint.c) receives bool repeat and bool running. Priority: repeat (amber) > running (green) > hot (blue).
repeat = true: border →COL_WARN, accent bar →COL_WARN, arrow →↻, text →COL_WARNrunning = true(and not repeat): border →COL_SUCCESS, accent bar →COL_SUCCESS, text →COL_SUCCESS
QB_Paint (quickbar.c) mirrors this: rep and run are computed per button via QB_GetFav; rep wins over run.
Console-mode guard
Console-mode scripts (run via cmd /k) do not post WM_SCRIPT_STOPPED, so the repeat trigger never fires and the running highlight never shows. Both BtnSubclassProc and QuickBarProc check g.cfg.show_console in the WM_LBUTTONDBLCLK handler and display a status-bar message instead of activating repeat.
Running Script Highlight Architecture
When a background (no-console) script runs, the button that was clicked turns green for the duration of the run.
State fields (AppState g)
| Field | Type | Purpose |
|---|---|---|
g.script_running |
bool |
true while a background script is in flight |
g.run_fi |
int |
Folder index of the running script |
g.run_si |
int |
Script index within that folder |
Flow
Runner_Run(runner.c) invalidates the old running button (ifg.script_runningis true) before overwritingg.run_fi = fi; g.run_si = si;, then creates the thread. This ensures only the newly launched script’s button turns green.Runner_ThreadpostsWM_SCRIPT_STARTEDonce the process is created.WM_SCRIPT_STARTEDhandler (main.c) setsg.script_running = true, invalidatesIDC_SCRIPT_BTN_BASE + g.run_si, and invalidateshwnd_qbar— both repaint green.WM_SCRIPT_STOPPEDhandler clearsg.script_running = falseand triggers the same repaints — both return to normal colour.
Paint condition
// tabs.c (main window)
bool running = g.script_running && !g.repeat_mode && g.run_fi == fi && g.run_si == idx;
// quickbar.c
bool run = g.script_running && !g.repeat_mode && g.run_fi == fi_btn && g.run_si == si_btn;
!g.repeat_mode ensures repeat (amber) always takes priority over the green running state.
Code Style
- C11 — designated initialisers,
bool,stdbool.h,_Static_assert - Unicode throughout —
WCHAR,L""literals,_snwprintf_s,wcslenetc. -
Memory-safe functions only — always use the
_s(C11 Annex K) or bounded variants; never use functions flagged byclang-analyzer-security.insecureAPI. The full substitution table:Never use Use instead Notes strcpy,wcscpystrcpy_s,wcscpy_salways pass _countof(dest)strcat,wcscatstrcat_s,wcscat_salways pass _countof(dest)strncpy,wcsncpystrncpy_s,wcsncpy_s_svariant guarantees NUL terminationsprintf,swprintfsprintf_s,swprintf_spass _countof(buf)snprintf,_snwprintf_snprintf_s,_snwprintf_suse _TRUNCATEas the count argumentfprintffprintf_svsprintf,vswprintfvsprintf_s,vswprintf_sgetsfgetsmemcpymemcpy_spass destSizethencountmemmovememmove_spass destSizethencountmemset(zeroing secrets)SecureZeroMemoryprevents compiler from eliding the zero - No CRT allocations in WndProcs where possible — use stack buffers
- Heap allocations — use
calloc/malloc+ matchingfree; forScriptFolderuse theFolder_Alloc/Folder_Free/Folder_Pushhelpers inmain.h - Double-buffered GDI — all painting via memory DC,
BitBltto screen - No globals except
AppState g— all state in the singlegstruct - Runtime colour functions —
COL_BG(),COL_TEXT()etc. return dark or light values based ong.dark_mode - PostStatus() for cross-thread status bar updates — uses
PostMessageto marshal to UI thread Util_Log()for debug output — goes toOutputDebugStringW, visible in Qt Creator Application Output