A free, local, open-source macOS and Windows GUI for GAM7 — administer Google Workspace (users, groups, signatures, delegates, vacation responders, reports, and more) without memorizing CLI commands, with credentials kept in the operating system's user credential store.
GAM exposes far more of Google Workspace than the Admin Console surfaces (Gmail signatures/delegates/forwarding, advanced group settings, bulk operations, reporting). GamGUI puts a safe, native front end on top of it.
Local source review, offline tests, documentation, and app builds do not contact Google and can be done without a live-change approval. Stop and obtain recorded administrator sign-off before any live Google-side authorization, OAuth or Domain-Wide Delegation scope/policy change, updater canary, pilot, or mutation. The approval must identify the Workspace tenant, the delegated canary subject (when applicable), the exact scopes or operation, the target set, and the approved window. A read-only canary is still a live Google-side action and is included in this gate.
For an installed app, sign-off may explicitly authorize the four-probe read-only canary for manual acceptance or a user-initiated verified-file update. The automatic startup updater does not run that canary or read Workspace credentials. Without that standing approval, do not complete the canary configuration or initiate a verified-file update. An approval for setup or canary reads does not authorize Classroom roster changes, Drive ownership/sharing changes, or any other mutation; obtain a separate mutation or bounded-pilot approval.
Actively developed and used against live Google Workspace tenants. Working today:
- Setup wizard — either import an existing GAM install (it auto-detects
$GAMCFGDIR,~/.gam, and its own setup dir, shows which credential files each one holds, and moves them into the Keychain) or follow the guided fresh GAM project / OAuth flow; then the manual domain-wide-delegation step and a verify. - Users — fast list/search/detail (cached + paginated), profile editing (title/department — location is shown but not editable) with a bulk "assign store" tool, mailbox delegates, vacation responders, group membership, per-user calendar sharing (grant/revoke access to that person's own calendar), sign out everywhere, and a guarded suspend.
- Gmail signatures — a scoped designer with variables, saved templates, a live preview, and bulk apply with a live per-user ✓/✗ feed as each signature gets set.
- Groups — membership management, including a drag-and-drop board.
- Calendars — instant indexed search at district scale; list resources or a person's calendars, inspect and grant/revoke ACLs, subscribe an individual or every current group member with bounded background progress, search events, and remove a stray event or orphaned secondary calendar.
- Classroom — locally indexed course search, provisioned-course creation, metadata and lifecycle changes, guarded owner transfer, and exact-preview teacher/student roster reconciliation.
- Drive — bounded per-user file search, metadata and sharing administration, safe file previews, and exact-manifest ownership transfer for a file, folder, or supported Classroom claim.
- Lifecycle — guided onboarding runbooks and a guarded offboarding routine (reset password → delegate → auto-responder → transfer Drive and calendars → unsubscribe calendars → manager reminder), with previews and progress evidence.
- Command Builder — search the categorized GAM catalog; curated commands get typed slots, guarded preview/run, sequencing, bounded interactive results and CSV download, plus explicit Google Sheet export for complete results.
- Reports and audit — 2SV gaps, inactive or suspended accounts, admins, missing recovery, storage/mail usage, directory completeness, and an incrementally indexed local audit trail.
- Cross-platform local updater — an installed macOS or per-user Windows app prepares only an
exact
district-maincommit that also ownsupdate-ready. Both platforms must pass the same exact-SHA promotion gate. Each updater verifies its locally signed bundle and offline self-test without reading Workspace credentials, then rolls back the app and local databases if activation health fails.
You build and run it yourself; it is not yet notarized for distribution to other Macs.
Destructive actions are guarded — but check what has actually been proven live. Suspend, account delete, calendar/event delete, data transfer, the offboarding routine, and bulk operations all run behind a preview → typed confirmation → audit-logged path. That guard is well covered by tests; what tests cannot prove is that a given GAM command behaves as expected against a real tenant. See Live verification status for which writes have been confirmed against a production domain and which have not — and run anything in the second list once on a throwaway user/event/calendar before you rely on it. Account deletion is reversible only within Google's ~20-day window. GamGUI is provided as-is under the MIT License, with no warranty — use at your own risk; you are responsible for what you run against your own tenant.
Every write is audited, so this list is derived from real audit logs rather than memory. "Confirmed live" means the operation has succeeded at least once against a production Google Workspace domain.
Confirmed live: calendar share (ACL) · calendar auto-subscribe (making a shared calendar appear
in someone's sidebar) · add calendar event · delete calendar · add delegate · remove group member ·
reset password · set organization fields · set signature · set vacation · transfer data · plus all
reads (a read-only pass over the parsers ships as scripts/acceptance.py).
Not yet confirmed live — treat as unproven and test on a throwaway target first: unshare a calendar (remove ACL) · remove delegate · clear vacation · add group member · sign out everywhere · delete event · delete user · the group fan-out of a calendar share (the individual calls it makes — ACL add and subscribe — are each confirmed live, but the group expansion itself is not) · and the two offboarding repairs described below.
Known-good repairs awaiting live re-run. Two offboarding bugs were found in real audit logs and
fixed, but the fixes have not themselves been exercised live yet: Drive and calendar are now
transferred in a single data-transfer call (two separate calls collided with a 409 conflict),
and "remove from everyone's calendars" now tolerates the cannotChangeOwnAcl error that used to
abort the sweep.
- Local & native — a bundled
.appon macOS or per-user application directory on Windows; no cloud service. The UI is served by a loopback-only local server on a random port, gated by a per-launch token (see Security model). - Secure — secrets live in the operating system's user credential store; GAM's plaintext files are
materialized into a locked-down temporary directory only for the duration of each
gaminvocation, then wiped. (details) - Easy but powerful — form/table UI for the common painful tasks, full GAM power underneath.
- Connector-ready — built around a connector protocol, so the Google Workspace connector is cleanly isolated and other systems could be added later without touching the UI.
HTMX views → FastAPI routes → Services → Connector protocol → GAMConnector
→ GAMRunner (subprocess)
→ SecretsVault (Keychain) + EphemeralConfig (temp GAMCFGDIR)
Bounded selectors/reports → domain-isolated SQLite indexes → background snapshot refresh
Classroom/Drive writes → live re-read → exact preview/manifest → guarded apply → audit
Wrapped in a pywebview native window (WKWebView). See CONTRIBUTING.md for the
layout and conventions, and docs/builder-commands.md for the Builder
catalog.
GamGUI avoids putting full-tenant payloads into ordinary pages:
- The user/group directory index is a persistent, domain-isolated SQLite snapshot containing only list/search fields. The first request builds the required snapshot; after 15 minutes it serves the existing snapshot while one background refresh runs. User detail remains a live exact lookup.
- The Classroom course index is also domain-isolated and persistent. Course pages search locally, return at most 50 rows, and schedule one background refresh when the 15-minute snapshot is stale. Course details, owners, and rosters are re-read live before a change.
- Drive does not cache a tenant-wide content listing. It queries one delegated user with narrow fields and cursor pagination, capped at 50 files per page.
- Directory and Classroom list pages are capped at 50. Builder pickers show at most 25 matches. A selected report finding returns at most 50 users. The audit page defaults to 25 entries and cannot request more than 50.
- Report summaries aggregate directly in SQLite. The audit index advances from the last indexed JSONL byte rather than re-reading the complete audit log on every page.
Indexes are derived local data, not credentials, but they can contain district identifiers. Keep the app-data directory and backups owner-only. Deleting a directory or Classroom index is recoverable—the next refresh rebuilds it from Google—but deleting the append-only audit JSONL is not.
Classroom search is local and paginated; selection loads live detail and roster data. GamGUI can
create a course in PROVISIONED, edit writable course metadata, activate a provisioned course,
archive/reactivate an active course, transfer ownership to a verified active internal user, and
add/remove teachers or students. Direct enrollment is restricted to the connected domain.
Bulk roster input accepts pasted text or CSV, computes an exact add/remove diff, and persists a restart-safe manifest. Apply re-reads the live roster and refuses a stale preview. A preview cannot be reused; removals require typing the exact course ID. Owner transfer requires both the exact course ID and exact destination email. The course owner cannot be removed from the teacher roster.
Classroom Teacher Access is a separate Classroom page for the domain's existing special Classroom Teachers group. Its live directory pickers can combine multiple AD/GCDS-synchronized Google groups with active users from one or more organizational units, including child OUs. CSV remains an explicit alternative source. Every source or exception change creates a fresh exact preview; source membership is re-read before apply, and scheduled runs hold on drift or safety thresholds.
Drive search is scoped to one delegated user and their owned, non-trashed My Drive files. GamGUI can show narrow metadata and permissions, edit metadata, add/update/remove eligible internal-user or internal-group permissions, preview supported content, and transfer ownership after a live capability/owner check. Owner and inherited permissions are not editable here.
Single-file and recursive ownership transfers require the exact destination email. Recursive folder transfers and Classroom ownership claims create persistent exact-file manifests, process targets serially, and retain per-file status for retry/review. Shared Drive content is organization-owned and is never transferred or claimed by these workflows.
OneRoster Import Studio is a first-party optional application profile. The core profile keeps
all ordinary Classroom and Drive administration but omits the OneRoster executable package,
templates, and migrations. The classroom-oneroster profile adds validated OneRoster ZIP
ingestion, import thresholds, immutable district manifests, and the student-enrollment release
gate. Each retained import also has a local class-naming step with common schemes and a safe custom
template using {course_title}, {class_code}, {class_title}, and {school_year}. Naming
changes rebuild the bounded preview and GAM-ready exports without changing stable Section_
aliases or contacting Google.
New installations offer the component during first-run setup. It can also be installed, disabled, enabled, or removed from Settings → Components. Installing or removing it stages a complete matching GamGUI bundle and activates it on restart; GamGUI never loads downloaded Python from Application Support. Component discovery and installation do not contact Google or read Workspace credentials.
Removing the component preserves its protected audit and import state. Raw and normalized OneRoster snapshots retain their 30-day expiry and are purged by Core. Permanent local data removal is a separate typed-confirmation action.
See Classroom OneRoster guided operations for the guided flow, verified-step Monitoring behavior, heartbeat escalation, safe pause, Recovery, sanitized receipts, and the cross-platform exact-SHA update boundary.
| Surface | Enforced bound or confirmation |
|---|---|
| Directory, Classroom, and Drive pages | Maximum 50 results per page |
| Builder target picker / sequence | 25 picker matches; 25 command steps |
| Report detail / audit | 50 report rows; 25 audit rows by default and 50 maximum |
| Classroom roster upload | 1,000,000 bytes maximum |
| Classroom roster reconciliation | 200 total adds plus removals per preview |
| OneRoster live planning | 50,000 classes, 300,000 active source enrollments, and 300,000 resulting actions; larger valid snapshots remain inspectable/exportable |
| OneRoster live roster snapshot | 500,000 deduplicated Classroom memberships |
| Drive content preview | 10 MiB maximum and only the supported safe MIME types |
| Drive folder/claim manifest | 500 exact files maximum |
| Generic action guard | Bulk starts at 10 targets; destructive bulk requires typed confirmation; over 200 is an explicit warning threshold, not an automatic refusal |
These controls reduce blast radius; they do not replace administrator approval, a reviewed target list, a small pilot, or post-change verification.
GAM stores credentials as plaintext files (client_secrets.json, oauth2.txt,
oauth2service.json) in its config dir. oauth2service.json can impersonate any user in the
domain and oauth2.txt is effectively an admin password, so GamGUI:
- keeps the canonical copies in the Keychain (
keyring, device-bound, not synced); - materializes them into a
chmod 700temp dir (fileschmod 600) set asGAMCFGDIRonly for eachgamcall; - wipes that dir on completion (success or failure) — and, because "the app quit mid-call" is the
case that actually strands plaintext credentials, also via an
atexithook, a graceful-shutdown timeout that lets in-flight calls unwind, and an owner-PID marker so a later run can collect a directory whose owning process is gone; - writes refreshed OAuth tokens back to the Keychain.
Beyond the credentials themselves:
- The local server is not open to other local processes. It binds loopback on a random port and
requires a per-launch token — and because cookies are not port-scoped (so
SameSitealone would treat every port on127.0.0.1as the same site), it also rejects cross-origin callers outright. - GAM is never invoked through a shell. Every command is an explicit argv list built by
GAMCommands; user input is always a single list element, never string-interpolated. - Every mutation is guarded and audited —
guard.evaluate()classifies risk and resolves the concrete affected set for a preview, and the write is appended to a local audit log. - The vendored
gambinary is checksum-pinned and verified fail-closed. A release asset with no committed pin is refused rather than installed, since a swapped binary would inherit domain-wide impersonation.
This is the handoff order for a new administrator. Do not skip the approval checkpoints.
-
Prepare locally. Install Python, clone the repository, run
make setup, vendor the pinned GAM build withmake gam, and runmake test. These steps need no tenant credentials. -
Record setup approval. Before opening a Google authorization page, get administrator sign-off for the tenant, super-admin/canary subject, GAM OAuth setup, and the exact feature scopes below. Decide whether the approval covers recurring read-only updater canaries.
-
Run setup. In the Setup screen, either import an existing GAM credential directory or use the fresh flow. The fresh flow provides these GAM7 commands in this order:
gam create project <super-admin-email> gam oauth create gam create svcacct
Use the commands rendered by the app so the bundled binary path and private
GAMCFGDIRare correct. GamGUI imports the resulting credentials into Keychain. -
Authorize Domain-Wide Delegation. Preserve the scopes already created by GAM. Add exactly these district feature scopes—do not replace the existing list with only these six:
https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/classroom.courses https://www.googleapis.com/auth/classroom.rosters https://www.googleapis.com/auth/classroom.profile.emails https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/driveThe setup screen renders the same comma-separated value and the service-account client ID. Changing these scopes or any Admin Console policy requires the recorded sign-off from step 2.
-
Verify setup. Click Verify access. GamGUI first verifies GAM's existing service-account authorization, then checks those six feature scopes. A passing verification activates the connector and stores the approved canary subject locally for manual acceptance or a user-initiated verified-file update.
-
Approve and run the live acceptance pass. A live canary requires explicit sign-off even though it is read-only:
.venv/bin/python scripts/acceptance.py
The fixed denominator is one exact user read with two projected fields, one Directory group page with
maxResults=1, one Classroom course page withpageSize=1, and one Drive file page withpage_size=1, plus a local GAM-version check. Output contains only fixed check names, PASS/FAIL, and elapsed milliseconds. Persisted canary evidence contains only check names, booleans, timings, and a timestamp—never tenant identifiers, full records, resource IDs, tokens, or exception text. Exit codes are0pass,1failed check, and2missing/incomplete canary setup. -
Approve a bounded pilot before mutations. Use throwaway or specifically approved targets, start add-only where possible, review the exact preview/manifest, stay below the documented caps, and verify Google-side results before expanding. Setup or canary approval alone is not mutation approval.
Requirements: Python 3.10+ and uv 0.11.7 on macOS or Windows (the test suite also runs on
Linux). No Google credentials are needed to build or test. The exact
Python dependency graph, including PyInstaller 6.20.0, is committed in uv.lock; make setup
fails rather than silently updating it.
git clone <repo-url> && cd gamgui
make setup # create/sync .venv from the frozen uv.lock
make gam # vendor the pinned GAM7 binary into gamgui/resources/gam7 (needs network)
make test # offline test suite — uses a mock gam, no binary/credentials required
make run # launch the app (native window; prints a browser URL if pywebview is absent)make help lists all targets. The supported reproducible path is make setup; direct editable
pip install commands bypass uv.lock and are intended only for dependency development. After an
intentional dependency change, regenerate and review the lock with exactly uv 0.11.7, then run
uv lock --check and the complete offline suite.
The GAM7 binary is not committed (platform-specific, large) — make gam / scripts/fetch_gam.sh
fetches the pinned version (v7.47.06) from the official releases and records its checksum.
make setup auto-selects a usable Python 3.10+ when PYTHON is unset; set
PYTHON=/path/to/python to choose one explicitly.
make app PROFILE=core # Core Classroom/Drive administration
make app PROFILE=classroom-oneroster # Core plus OneRoster Import StudioEach command builds dist/GamGUI.app from the selected sealed profile. For distribution to other
Macs, both profiles must be signed and notarized independently, including the embedded GAM binary;
running them on the managed development Mac uses the stable local signing identity described
below.
Windows supports the same fixed core and classroom-oneroster profiles. A developer build uses
the locked environment and checksum-pinned GAM payload:
uv sync --frozen --python (Get-Command python).Source --extra dev --extra desktop --extra build
.\scripts\build_windows_release.ps1 -Profile core
.\scripts\build_windows_release.ps1 -Profile classroom-onerosterThe first-install deliverable is one native, offline Inno Setup wizard containing both profiles and
the committed, checksum-pinned MinGit and uv archives. Classroom + OneRoster is selected by default.
It installs the app under %LOCALAPPDATA%\Programs\GamGUI\current, keeps data under
%LOCALAPPDATA%\GamGUI, and copies GamGUIUpdater.exe outside the replaceable application
directory. The Setup executable is an unsigned manual trust boundary: verify its release SHA-256
before using SmartScreen's More info > Run anyway path. The wizard explains and requires
consent before it creates the ten-year, non-exportable RSA-3072 GamGUI Local identity or adds its
public certificate to the current user's Trusted Root and Trusted Publisher stores.
After Setup, updates are automatic local builds signed by that pinned identity. The updater
will not accept a changed certificate, unsigned executable, altered bundle manifest, stale SHA, or
backward/non-descendant revision. Do not delete or rotate GamGUI Local manually. Use the bundled
Apps & Features uninstall removes the app, helper, toolchain, shortcuts, and local certificate while
preserving application data unless Also delete local application data is explicitly selected.
First install is offline. Later update preparation needs GitHub, Python-package, and GAM-release access. Allow enough disk space for the source checkout, build environment, current app, pending app, rollback copy, and an additional 256 MiB safety reserve; 4 GiB free is a practical minimum. A network, pin, signing, space, or self-test failure leaves the installed application unchanged. See the complete Windows setup, SmartScreen, GPO, recovery, and release guide.
By default the app is ad-hoc signed, so macOS treats each rebuild as a new identity and
re-prompts for the Keychain on every launch — and "Always Allow" never sticks. The fix (no Apple
Developer account needed — that's only for shipping the app to other people's Macs) is a stable
self-signed code-signing cert named GamGUI Local. Once it exists in your login keychain,
scripts/build_app.sh signs with it automatically, so your one-time Always Allow persists
across launches and rebuilds.
Create it once, either way:
- GUI: Keychain Access → Certificate Assistant → Create a Certificate… → name it
GamGUI Local, Identity Type Self-Signed Root, Certificate Type Code Signing. - CLI:
D=$(mktemp -d) printf '[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n[dn]\nCN=GamGUI Local\n[v3]\nbasicConstraints=critical,CA:false\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=critical,codeSigning\n' > "$D/c.cnf" openssl req -x509 -newkey rsa:2048 -keyout "$D/k.pem" -out "$D/c.pem" -days 3650 -nodes -config "$D/c.cnf" openssl pkcs12 -export -inkey "$D/k.pem" -in "$D/c.pem" -out "$D/id.p12" -passout pass:gamgui-local -name "GamGUI Local" security import "$D/id.p12" -P gamgui-local -T /usr/bin/codesign && rm -rf "$D"
Then rebuild the desired profile. The first launch still asks once per credential — click
Always Allow on each — and you won't be prompted again, even after future rebuilds. (Override
the cert name with CODESIGN_IDENTITY=…. The cert is local and not trusted for distribution by
design — it only quiets your own Keychain.)
The app also caches the three secrets in-process for a sliding window (default 5 min) so a burst of
actions doesn't re-prompt; tune with GAMGUI_SECRET_CACHE_TTL (seconds; 0 disables).
On macOS, refreshed OAuth data updates the existing Keychain item in place so its per-item
Always Allow authorization survives token refreshes.
| Branch | Purpose |
|---|---|
main |
A no-force mirror of upstream goetchstone/gamgui:main. Do not land district-only code or deploy from this branch. |
district-main |
The protected default and deployment branch. It contains upstream plus the district upgrade and is the only branch the installed updater follows. |
.github/workflows/upstream-sync.yml advances the local main mirror, creates a temporary
integration branch from district-main, merges main there, and opens a PR back to
district-main. A conflict opens/updates an issue and leaves district-main unchanged. Upstream
sync and GAM-pin maintenance share the district-maintenance concurrency group, so those writers
run serially.
CI runs on integration PRs. A push to district-main dispatches post-merge validation for the exact
40-character commit SHA. That workflow rejects a moving/mismatched branch, runs Linux, macOS, and
Windows tests on Python 3.10/3.12/3.14, checks GAM contracts, and builds/self-tests both profiles on
macOS and Windows. Windows validation uses an ephemeral runner certificate and labels its bundles
validation-only. It publishes update-ready only when every platform gate passes. A green PR check
alone is not local-update evidence; the updater requires update-ready on the exact current
district-main SHA.
GamGUI pins a tested GAM7 version — EXPECTED_GAM_VERSION in gamgui/core/gam/commands.py, matched by
scripts/fetch_gam.sh. The tested pin is currently GAM 7.47.06. The running app never downloads
or substitutes an unpinned GAM binary:
- Automated pin PR (
.github/workflows/gam-update.yml) compares the latest GAM release with the pin, runsscripts/bump_gam.py, downloads both supported macOS assets using GitHub-published SHA-256 metadata, regenerates the catalog/version contracts, runs focused tests, and opens an auto-merge PR todistrict-main. Branch protection and exact-SHA post-merge validation still gate deployment. - Compatibility checks assert every GAM sub-command our builders use still exists in the
vendored command reference. Post-merge validation checks both the pin and current latest release,
so a renamed/removed command fails before an installed app can see
update-ready. - Runtime self-check — if the running
gamdiffers from the tested version (e.g. aGAMGUI_GAM_BINARYoverride), the setup screen shows a soft warning. It never blocks.
To perform or reproduce a GAM bump manually:
- Run
python3 scripts/bump_gam.py --tag vX.Y.Z. It fails closed unless signed release metadata contains SHA-256 digests for both supported macOS architectures, then updates the checksum catalog, version constants, mock, README marker, vendored reference, and command catalog. - Review the diff and
gamgui/resources/gam7/GamUpdate.txtfor behavior or scope changes. - Run
pytest -q tests/test_command_contract.py tests/test_bump_gam.py, thenmake test. - After separate administrator sign-off for a live canary, run
.venv/bin/python scripts/acceptance.py. - Open the scoped PR to
district-main; do not bypass protected-branch CI or exact-SHA post-merge validation.
The updater runs only from an installed macOS GamGUI.app or Windows
%LOCALAPPDATA%\Programs\GamGUI\current; a source checkout or headless development server does not
self-update.
- At app startup, a background worker checks the head of
district-main. It defers while an administrative job or Classroom/Drive manifest is active. - It accepts only a new, non-blocklisted 40-character SHA whose completed check runs include a
successful
update-readyfor that same SHA. - It clones and checks out that exact SHA detached, requires exactly
uv 0.11.7, synchronizes the committed frozen lock, fetches checksum-pinned GAM, and builds the selected profile. Windows uses the bundled, version-checked MinGit and uv toolchain; macOS uses its existing local builder. It never falls back fromclassroom-onerostertocore. It requires the pinned localGamGUI Localsigning identity, verifies the signature and complete bundle manifest, and runs the offline self-test. - Exact-SHA CI, the sealed artifact identity, local signing verification, and the bundled offline self-test establish automatic-update readiness. The automatic startup path does not run the live Workspace canary or read operating-system credentials.
- A passing build is staged while the current app keeps running. On the next launch, a native
dialog explains the restart and lets the administrator install now or defer. If accepted, a
helper snapshots the current app and all local SQLite databases, tests schema preparation on a
copy, swaps the bundle, and obtains a startup health marker from a hidden verification window
within 45 seconds before reopening the normal app. Windows uses a per-user named mutex and a
detached helper so no process inside
currentreplaces itself. - If the helper's migration-copy self-test, bundle swap, or startup health check fails, it restores the prior app and database snapshot, relaunches the old app, and blocklists that SHA. Successful activation keeps at most two rollback backups, and backups older than 30 days are pruned.
Network, toolchain, signing, or self-test preparation failures leave the installed version untouched and are retryable; they do not blocklist the SHA. The app shows only a generic local notice, not paths, commands, or tenant data. A user-initiated verified-file update separately runs the four bounded, read-only canary probes in a disposable app-data directory and requires prior administrator approval for the configured canary subject. The updater never changes OAuth/DWD scopes, Admin Console policy, or tenant data, and it never treats a missing/failed required canary as approval to proceed.
After update-ready advances, verify an actual computer separately. On Windows, inspect
%LOCALAPPDATA%\GamGUI\updates\state.json and %LOCALAPPDATA%\Programs\GamGUI\current.artifact.json,
run GamGUI.exe --self-test --json, confirm gam.exe version, and use Get-AuthenticodeSignature
plus the pinned signer SHA-256. On macOS, inspect the corresponding Application Support state and
artifact receipt, run the bundle self-test and bundled GAM version, and verify codesign. In both
cases the installed source SHA, profile, component digest, architecture, GAM version, signature,
self-test, runtime health, and preserved application data must agree before calling the machine
updated.
pytest is fully offline (mock GAM + in-memory secret store). CI runs it on Ubuntu, macOS, and
Windows across Python 3.10, 3.12, and 3.14 — see
.github/workflows/ci.yml.
Static analysis. CodeQL runs on every push and PR to main, plus weekly, over both the Python
code and the workflows themselves — configured in-tree so it is reviewable rather than hidden in
repository settings: .github/workflows/codeql.yml with
.github/codeql/codeql-config.yml. It uses the broader
security-extended suite, and skips tests/, the vendored GAM release, and vendored browser
libraries — the config explains why for each.
The Signatures screen designs one HTML signature with variables, previews it rendered for a real person, and applies it in bulk — scoped to a single user (for testing), a group, an org unit, a department, a location, or the whole company. Each user's current signature is also shown rendered on their detail page.
Template variables (filled per user from the directory):
{name} {first} {last} {email} {title} ({role} is an alias) {phone} {department}
{location} {ou}. Wrap a fragment in [[ … ]] to drop it when a variable inside is empty — e.g.
[[{title} · ]] vanishes for people with no title, so one template can roll out before every profile
is filled in.
Gmail does not allow inline/base64 images or Google Drive links in signatures — every image must
be a file at a public HTTPS URL. GamGUI is a local app and doesn't host images itself; you point
the template's <img src="…"> at wherever you host them. Whatever host you choose, the URL must be:
- HTTPS and anonymously reachable — Gmail fetches images through its own proxy (no cookies/referer) and caches them. Test a URL in a private/incognito window; if it loads there, Gmail can fetch it.
- served with the correct
Content-Type(image/png, …) and no hotlink/referer protection — referer-based protection is the usual cause of "the logo shows for me but not for recipients." - versioned by filename when an image changes (
logo-2026.png) — Gmail caches by URL, so overwriting the same name can keep serving the old one.
Size icons ~2× their display size and set explicit width/height on each <img>.
Where to host — pick one:
- A web host you already have (simplest). Drop the files in a public folder, e.g.
https://yourdomain.com/email/logo.png. Done. - Google Cloud Storage (Google-native; reuse the GCP project GAM created). Requires a billing
account linked to the project — but small signature assets fall under the Always-Free tier, so
the bill rounds to $0:
- Cloud Console → Billing → link a billing account to the project (if not already).
- Cloud Storage → Create bucket — globally-unique name, a US region, Standard class, Uniform bucket-level access.
- Make objects public: bucket Permissions → Grant access → principal
allUsers→ roleStorage Object Viewer. (If your org enforces Public access prevention, allow it on this bucket.) - Upload the images.
- Reference them at
https://storage.googleapis.com/<bucket>/<path>/logo.png. (Pricing changes — confirm the current free-tier limits, but for a handful of small PNGs it is effectively free.)
- GitHub + jsDelivr (free, no billing). Commit the images to a public repo and serve them via the
jsDelivr CDN:
https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch>/path/logo.png. CDN-fast, no card. - Cloudflare R2 / Amazon S3 — or any public-object store — also work.
ROADMAP.md — the ranked backlog, plus the trade-offs behind what it does not do yet. CONTRIBUTING.md — layout, conventions, and how to add a Builder command. SECURITY.md — threat model, the invariants the code is expected to hold, and how to report a vulnerability privately.
MIT — see LICENSE.