From 9cfcd7bca0a6ad1c947cf726211975c7100d82c2 Mon Sep 17 00:00:00 2001 From: blendonl Date: Tue, 27 Oct 2026 04:36:55 -0700 Subject: [PATCH] feat(install)!: install from the MSI only, with a new default config - Replace install.bat/uninstall.bat and the release zip with a WiX v5 MSI built on Windows: per-machine to Program Files, with features for the shell value, the helper task, the Windows tweaks and the bundled mrun, mnotify and mcapture companions - Pin the bundled companions in packaging/companions.json with their sha256 - Sign the binaries and the MSI when the signing secrets are set - Publish winget manifests with each release and submit them when WINGET_TOKEN is set - config.update downloads, verifies and runs the MSI, then restarts - mshell seeds %APPDATA%\mshell on first start, shows a welcome notification, and drops a changed default beside the user's config as init.lua.new - Rewrite the default init.lua: chords only while Win is held, a one-shot leader menu on a Win tap, desktop go/send/follow bindings, companion integration with fallbacks, and the Catppuccin Mocha palette - Add config.restart and multi-group --tweaks --- .github/scripts/winget-manifest.py | 100 ++++++++ .github/workflows/build.yml | 26 +- .github/workflows/release.yml | 134 ++++++++-- CHANGELOG.md | 53 ++++ INSTALL.md | 260 ++++++++++---------- MANUAL-TESTS.md | 148 ++++++----- Makefile | 61 ++--- README.md | 140 +++++++---- config/init.lua | 378 +++++++++++++++++------------ harden.reg | 2 +- install.bat | 271 --------------------- meta/mshell.lua | 3 + packaging/build-msi.ps1 | 138 +++++++++++ packaging/companions.json | 14 ++ packaging/mshell.wxs | 245 +++++++++++++++---- src/action_table.h | 1 + src/actions.c | 2 + src/api_spec.c | 4 + src/api_spec.h | 1 + src/cli.c | 24 +- src/helper.c | 8 +- src/main.c | 184 +++++++++++++- src/mshell.h | 1 + src/update.c | 155 ++++++------ src/window_place.c | 4 +- src/window_visibility.c | 4 +- uninstall.bat | 61 ----- 27 files changed, 1491 insertions(+), 931 deletions(-) create mode 100755 .github/scripts/winget-manifest.py delete mode 100644 install.bat create mode 100644 packaging/build-msi.ps1 create mode 100644 packaging/companions.json delete mode 100644 uninstall.bat diff --git a/.github/scripts/winget-manifest.py b/.github/scripts/winget-manifest.py new file mode 100755 index 0000000..dfe1486 --- /dev/null +++ b/.github/scripts/winget-manifest.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import pathlib + +PACKAGE = "blendonl.mshell" +REPO = "https://github.com/blendonl/mshell" +MANIFEST_VERSION = "1.6.0" + + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest().upper() + + +def header(version, kind): + return [ + f"# yaml-language-server: $schema=https://aka.ms/winget-manifest.{kind}.{MANIFEST_VERSION}.schema.json", + "", + f"PackageIdentifier: {PACKAGE}", + f"PackageVersion: {version}", + ] + + +def footer(kind): + return [f"ManifestType: {kind}", f"ManifestVersion: {MANIFEST_VERSION}", ""] + + +def version_manifest(version): + return header(version, "version") + ["DefaultLocale: en-US"] + footer("version") + + +def installer_manifest(version, msi, product_code): + lines = header(version, "installer") + [ + "InstallerType: wix", + "Scope: machine", + "UpgradeBehavior: install", + "InstallModes:", + " - interactive", + " - silent", + " - silentWithProgress", + ] + if product_code: + lines.append(f"ProductCode: '{product_code}'") + lines += [ + "Installers:", + " - Architecture: x64", + f" InstallerUrl: {REPO}/releases/download/v{version}/{msi.name}", + f" InstallerSha256: {sha256(msi)}", + ] + return lines + footer("installer") + + +def locale_manifest(version): + return header(version, "defaultLocale") + [ + "PackageLocale: en-US", + "Publisher: blendonl", + "PublisherUrl: https://github.com/blendonl", + f"PublisherSupportUrl: {REPO}/issues", + "PackageName: mshell", + f"PackageUrl: {REPO}", + "License: MIT", + f"LicenseUrl: {REPO}/blob/main/LICENSE", + "ShortDescription: A keyboard-driven tiling window manager that replaces explorer.exe as the Windows shell.", + "Moniker: mshell", + "Tags:", + " - tiling-window-manager", + " - window-manager", + " - shell", + " - lua", + f"ReleaseNotesUrl: {REPO}/releases/tag/v{version}", + ] + footer("defaultLocale") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--version", required=True) + p.add_argument("--msi", required=True, type=pathlib.Path) + p.add_argument("--product-code", default="") + p.add_argument("--out", required=True, type=pathlib.Path) + a = p.parse_args() + + out = a.out / "manifests" / PACKAGE[0].lower() / "blendonl" / "mshell" / a.version + out.mkdir(parents=True, exist_ok=True) + + files = { + f"{PACKAGE}.yaml": version_manifest(a.version), + f"{PACKAGE}.installer.yaml": installer_manifest(a.version, a.msi, a.product_code), + f"{PACKAGE}.locale.en-US.yaml": locale_manifest(a.version), + } + for name, lines in files.items(): + (out / name).write_text("\n".join(lines), encoding="utf-8") + print(out / name) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f3e635..7f1f6ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,5 +45,27 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: mshell-win64 - path: dist/*.zip + name: stage + path: dist/stage + + msi: + needs: build + runs-on: windows-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: stage + path: dist/stage + + - name: Build the installer + shell: pwsh + run: ./packaging/build-msi.ps1 -Version 0.0.1 -Stage dist/stage -Out dist/mshell-test.msi + + - uses: actions/upload-artifact@v4 + with: + name: mshell-msi + path: dist/*.msi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcb7a9c..9c088ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,24 +12,20 @@ concurrency: cancel-in-progress: false jobs: - release: + build: if: "!contains(github.event.head_commit.message, '[skip release]')" runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install mingw-w64 and wixl + - name: Install mingw-w64 run: | sudo apt-get update - sudo apt-get install -y gcc-mingw-w64-x86-64 wixl - - command -v wixl >/dev/null || { - echo "::error::wixl is not on PATH after installing it"; exit 1; } - wixl --version + sudo apt-get install -y gcc-mingw-w64-x86-64 - name: Host-side unit tests run: make test @@ -83,27 +79,99 @@ jobs: echo "--- release notes ---" cat "$RUNNER_TEMP/notes.md" - - name: Build and package - run: make CFLAGS_EXTRA=-Werror dist msi + - name: Build and stage + run: make CFLAGS_EXTRA=-Werror dist + + - name: Keep the release notes and the bumped files + run: cp Makefile CHANGELOG.md "$RUNNER_TEMP/notes.md" dist/ + + - uses: actions/upload-artifact@v4 + with: + name: stage + path: dist/stage + + - uses: actions/upload-artifact@v4 + with: + name: release-meta + path: | + dist/Makefile + dist/CHANGELOG.md + dist/notes.md + dist/*-symbols.zip + + msi: + needs: build + runs-on: windows-latest + env: + GH_TOKEN: ${{ github.token }} + MSHELL_SIGN_PFX_BASE64: ${{ secrets.MSHELL_SIGN_PFX_BASE64 }} + MSHELL_SIGN_PFX_PASSWORD: ${{ secrets.MSHELL_SIGN_PFX_PASSWORD }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: stage + path: dist/stage + + - name: Build the installer + shell: pwsh + run: > + ./packaging/build-msi.ps1 + -Version ${{ needs.build.outputs.version }} + -Stage dist/stage + -Out dist/mshell-${{ needs.build.outputs.version }}.msi + + - uses: actions/upload-artifact@v4 + with: + name: msi + path: dist/*.msi + + publish: + needs: [build, msi] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + NEW: ${{ needs.build.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + name: release-meta + path: dist + + - uses: actions/download-artifact@v4 + with: + name: msi + path: dist - name: Check every artifact exists - env: - NEW: ${{ steps.version.outputs.version }} run: | - for f in "dist/mshell-$NEW-win64.zip" \ - "dist/mshell-$NEW-win64-symbols.zip" \ + for f in "dist/mshell-$NEW-win64-symbols.zip" \ "dist/mshell-$NEW.msi"; do test -s "$f" || { echo "::error::$f was not produced"; exit 1; } done - ls -l "dist/mshell-$NEW-win64.zip" \ - "dist/mshell-$NEW-win64-symbols.zip" \ - "dist/mshell-$NEW.msi" + ls -l dist + + - name: Write the winget manifests + run: | + sudo apt-get update + sudo apt-get install -y msitools + code=$(msiinfo export "dist/mshell-$NEW.msi" Property | awk -F'\t' '$1=="ProductCode"{print $2}' | tr -d '\r') + python3 .github/scripts/winget-manifest.py \ + --version "$NEW" \ + --msi "dist/mshell-$NEW.msi" \ + --product-code "$code" \ + --out dist/winget + (cd dist/winget && zip -r "../mshell-$NEW-winget.zip" manifests) - name: Commit the bump back to main id: commit - env: - NEW: ${{ steps.version.outputs.version }} run: | + cp dist/Makefile dist/CHANGELOG.md . git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Makefile CHANGELOG.md @@ -112,13 +180,29 @@ jobs: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Publish - env: - NEW: ${{ steps.version.outputs.version }} run: | gh release create "v$NEW" \ --target "${{ steps.commit.outputs.sha }}" \ --title "mshell $NEW" \ - --notes-file "$RUNNER_TEMP/notes.md" \ - "dist/mshell-$NEW-win64.zip" \ + --notes-file dist/notes.md \ + "dist/mshell-$NEW.msi" \ "dist/mshell-$NEW-win64-symbols.zip" \ - "dist/mshell-$NEW.msi" + "dist/mshell-$NEW-winget.zip" + + winget: + needs: [build, publish] + runs-on: windows-latest + env: + GH_TOKEN: ${{ github.token }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + NEW: ${{ needs.build.outputs.version }} + steps: + - name: Submit to winget-pkgs + if: env.WINGET_TOKEN != '' + shell: pwsh + run: | + gh release download "v$env:NEW" -R "$env:GITHUB_REPOSITORY" -p "mshell-$env:NEW-winget.zip" -D winget + Expand-Archive "winget/mshell-$env:NEW-winget.zip" winget + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + ./wingetcreate.exe submit --token $env:WINGET_TOKEN "winget/manifests/b/blendonl/mshell/$env:NEW" + if ($LASTEXITCODE) { throw "wingetcreate failed ($LASTEXITCODE)" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 483160e..ede9f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ All notable changes to mshell are documented here. This project adheres to ## Unreleased +### Changed + +- **BREAKING: mshell installs from the MSI only.** `install.bat` and + `uninstall.bat` are gone, and so is the release zip. `mshell-.msi` + installs to `C:\Program Files\mshell` and offers, as checkboxes on its + *Custom Setup* page: using mshell as your shell, the privileged helper's + sign-in task, the Windows tweaks (including the five policy values that + used to need an elevated `install.bat`), and the companion apps **mrun**, + **mnotify** and **mcapture**, which are now bundled. Upgrading renames the + running images and hands over to the new build without a reboot or a + sign-out; uninstalling from *Apps* removes the shell value, the task, the + companions and the tweaks. The MSI is built with WiX v5 on Windows + (`packaging/build-msi.ps1`). +- **`config.update` runs the MSI.** `Win+Shift+u` downloads the release's + `.msi`, checks it against the published SHA-256, runs it with + `msiexec /passive` and restarts into the new build. +- **mshell writes its own default config.** The first start without + `%APPDATA%\mshell\init.lua` copies the shipped one there with its + `.luarc.json`, and every start refreshes `%APPDATA%\mshell\meta`. +- **A new default `init.lua`.** `h/j/k/l` only work while `Win` is held; a bare + `Win` tap opens a one-shot leader menu — `g` go to a desktop, `m` send the + window to one, `w` window, `l` layout, `r` resize, `a` apps, `v` media, + `s` session, `x` mshell. `Win+Ctrl+1..9` takes the window to a desktop and + follows it. `Win+Return` is the terminal (the first of `wt`, `pwsh`, `cmd` + that launches), `Win+Space` the launcher (mrun when installed), `Win+q` + closes. mnotify and `mrun --daemon` start with the session when installed, + and `Win+Shift+s` / `Win+PrintScreen` take screenshots. The colours are + Catppuccin Mocha, the palette mrun and mnotify already ship with. +- **New defaults land beside your config.** When a release changes the shipped + `init.lua` and yours differs from it, mshell copies the new one to + `%APPDATA%\mshell\init.lua.new` and says so in a notification. Yours is + never overwritten. +- **The bundled companions are pinned.** `packaging/companions.json` names the + mrun, mnotify and mcapture release each MSI ships and its SHA-256, so a + companion release no longer changes an mshell build. `-LatestCompanions` + takes the newest instead. + +### Added + +- `config.restart` (`mshell.exe --msg restart`) hands over to the build that is + installed now, the way an update does. +- `--tweaks apply` and `--tweaks revert` take several groups: + `--tweaks apply input apps`. +- A welcome notification on the first start explains the leader menu and the + main chords. +- Code signing: with the `MSHELL_SIGN_PFX_BASE64` and + `MSHELL_SIGN_PFX_PASSWORD` secrets set, the release signs every unsigned + binary and the MSI. A signed `mshelld.exe` turns on the helper's + same-certificate check of its clients. +- winget: each release attaches `mshell--winget.zip` with the + `blendonl.mshell` manifests, and submits them to winget-pkgs when the + `WINGET_TOKEN` secret is set. + ## 0.15.25 — 2026-10-15 - feat(power): configure the active power plan from `power.setup` and diff --git a/INSTALL.md b/INSTALL.md index 31f449d..4dc5878 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -4,103 +4,109 @@ This replaces `explorer.exe` — no taskbar, no tray, no desktop. Read the **Recovery** section before you start; a broken shell is easy to undo once you know the Task Manager trick. -## 0. Build (on Linux) +## 1. Run the installer + +Download `mshell-.msi` from the +[releases page](https://github.com/blendonl/mshell/releases) (or use +`winget install blendonl.mshell` once the package is listed) and run it **from +the account that will use mshell** — the shell setting and the Windows tweaks +are per-user, and they go to whoever ran the installer. Windows asks for +administrator approval once. + +The *Custom Setup* page lists what it can install. Everything is on by default: + +| Option | What it does | +|--------|--------------| +| **mshell** | `mshell.exe`, the helper binary, the default config and the editor type definitions, in `C:\Program Files\mshell`. Always installed. | +| **Use mshell as my Windows shell** | Sets the per-user (HKCU) Winlogon `Shell` value. Turn it off to try mshell in test mode first. | +| **Privileged helper** | Registers the `mshelld` sign-in task, so windows of elevated apps tile and follow their desktop. See [The privileged helper](#the-privileged-helper-recommended-instead-of-elevating-mshell). | +| **Windows tweaks** | Hands `Win`+key and PrintScreen to mshell, silences the Sticky/Filter/Toggle Keys prompts, and keeps Chrome and Edge drawing while hidden. See [section 5](#5-turn-off-the-rest-of-windows-optional-recommended). | +| **mrun** | The launcher behind `Win+Space`. Without it, mshell's built-in launcher is used. | +| **mnotify** | App notifications and tray icons, which Explorer would otherwise draw. | +| **mcapture** | Screenshots on `Win+Shift+s` / `Win+PrintScreen`. Without it, Windows' snipping overlay is used. | + +The default config adapts to whichever of these you picked, so turning one off +never leaves a key bound to a program that is not there. Run the MSI again (or +*Modify* it under *Apps*) to add or remove one later. + +Unattended, the feature ids are `Core`, `Shell`, `Helper`, `Tweaks`, `Mrun`, +`Mnotify` and `Mcapture`: ``` -make clean && make # produces mshell.exe +msiexec /i mshell-.msi /passive +msiexec /i mshell-.msi /passive ADDLOCAL=Core,Shell,Helper,Mrun ``` -You need `mshell.exe` and the `config/` folder on the Windows machine. - -## 1. Get the files onto Windows - -Copy these to the Windows box (USB, scp, network share) into one folder: - -``` -mshell.exe -config\init.lua -install.bat -uninstall.bat -``` - -`install.bat` then splits these into the two places they belong — the program -under `C:\mshell`, your config in your own profile: - -``` -C:\mshell\mshell.exe -%APPDATA%\mshell\init.lua (C:\Users\\AppData\Roaming\mshell\init.lua) -``` - -That's the standard Windows spot for per-user configuration, so your config is -never overwritten by reinstalling mshell and follows your roaming profile. An -existing `init.lua` there is always kept as-is; upgrading only replaces the exe. - -If `%APPDATA%\mshell\init.lua` does not exist, mshell falls back to -`config\init.lua` next to `mshell.exe` — which is what makes step 2 below work -straight out of the unzipped folder, before you install anything. +Your config lives at `%APPDATA%\mshell\init.lua`. The installer does not write +it: mshell copies the shipped default there the first time it starts without +one, so every account on the machine gets its own, and an existing `init.lua` +is never touched. That first start shows a notification with the keys to +begin with: tap `Win` for the menu, hold it for the chords. The editor type definitions beside it are refreshed on every +start, so they always describe the build you are running. ## 2. Try it WITHOUT committing (strongly recommended) -Before making it your shell, run it alongside Explorer: +Turn **Use mshell as my Windows shell** off, and on the last page tick *Try +mshell now in test mode*. Or, any time: ``` -mshell.exe --test +"C:\Program Files\mshell\mshell.exe" --test ``` -Tile some windows, switch desktops (Win+1..9), open a terminal -(Win+Shift+Return). Quit with **Win+Shift+Q** — in `--test` that just closes -the process, it does NOT log you out. If this feels right, continue. +Tile some windows, switch desktops (`Win+1..9`), open a terminal +(`Win+Return`), tap `Win` alone for the leader menu. Quit from the leader menu, +`x` then `q` — in `--test` that just closes the process, it does NOT log you +out. If this feels right, run the MSI again and turn the shell option on. -## 3. Install as the shell +## 3. Sign out and back in -Double-click **`install.bat`** (or run it from a terminal). It copies the -program to `C:\mshell`, installs the default config to `%APPDATA%\mshell\init.lua` -(keeping yours if one is already there), and sets the per-user shell key: +mshell is now your shell. Explorer never launches. This is **per-user**: it only +affects the account that ran the installer, and overrides the system default +(`explorer.exe`) just for you: ``` HKCU\Software\Microsoft\Windows NT\CurrentVersion\Winlogon - Shell = "C:\mshell\mshell.exe --shell" + Shell = "C:\Program Files\mshell\mshell.exe" --shell ``` -This is **per-user (HKCU)** — it only affects your account, and it overrides -the system default (`explorer.exe`) just for you. Other accounts still get -Explorer. Run `install.bat` as the account that will use mshell: both `HKCU` -and `%APPDATA%` follow whoever runs the script. +If Windows asked for a *different* administrator's password rather than a +Yes/No, the installer ran as that account and set *its* shell. Sign in as the +administrator, or run the MSI from an account that is an administrator itself. -> Upgrading from 0.3 or earlier? `install.bat` moves an existing -> `C:\mshell\config\init.lua` to `%APPDATA%\mshell\init.lua` for you, so your -> keybindings carry over. +### Upgrading -**Sign out and back in.** mshell is now your shell. Explorer never launches. - -### Reinstalling / upgrading - -Running `install.bat` again is the upgrade path, and it always wins over what -is already installed: +Run a newer MSI, or press `Win+Shift+u` to have mshell fetch, verify and run +the latest one for you. Either way: - **The exe is replaced even while it is your running shell.** Windows locks - the image of a running process, so the installer renames the old one to - `mshell.exe.old` and drops the new one in its place. -- **Then it restarts mshell for you**, so the build you just installed is the - one running — no sign-out. Your windows are left where they are; the new - instance picks them up. (If mshell wasn't running, there is nothing to - restart and it simply starts at your next sign-in.) -- **Your config is the exception** — an existing `%APPDATA%\mshell\init.lua` is - never overwritten. - -The restart works by stopping the running mshell: Windows then puts the shell -back from the `Shell` value above — the new exe — and the installer only starts -it itself if Windows didn't. That safety net is Winlogon's `AutoRestartShell` + the image of a running process, so the installer renames it — and the + helper, mrun, mnotify and mcapture — to `*.old` and drops the new one in its + place. Nothing waits for a reboot. +- **Then mshell hands over to the new build** — `Win+Shift+u` does it as soon as + the installer finishes, and an MSI you ran yourself does it when you press + *Finish*. Your windows are left where they are; the new instance picks them + up and starts mrun and mnotify again. `mshell.exe --msg restart` does the + same by hand. +- **Your choices carry over**, and so does your config — an existing + `%APPDATA%\mshell\init.lua` is never overwritten. When the release changes + the default and yours differs from it, the new default is copied to + `%APPDATA%\mshell\init.lua.new` and a notification says so; diff the two + and take what you want. It is written once per new default, so deleting it + keeps it gone. + +The hand-over works by mshell exiting: Windows then puts the shell back from +the `Shell` value above. That safety net is Winlogon's `AutoRestartShell` (`HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`, `1` unless someone turned it off). **If it is `0`, a shell that exits logs the session -off**, so the installer checks it first and skips the restart rather than -dropping you at the login screen with apps open. +off**, so mshell checks it first and tells you to sign out instead. + +### Coming from `install.bat` (0.15 and earlier) -If the installer can't stop the running mshell, or skips the restart, it says -so — the upgrade is still installed, so sign out and back in to switch to it. -Note that a restart launched by the installer inherits the installer's token, -so an `install.bat` you ran elevated leaves mshell elevated until your next -sign-in. +Earlier releases installed to `C:\mshell` with a batch script. The MSI points +your `Shell` value and the `mshelld` task at `C:\Program Files\mshell` and +leaves the old folder alone — sign in once on the new build, then delete +`C:\mshell`. Tweaks applied by the old script stay recorded, so +`mshell.exe --tweaks revert input apps` still puts your previous values back. ## 4. Elevation (optional, for full window control) @@ -117,7 +123,7 @@ elevated. In order of preference: - **Run mshell elevated via a logon scheduled task (keeps UAC on).** Create a task set to *Run with highest privileges*, triggered *At log on* for your - account, that starts `C:\mshell\mshell.exe --shell`, and point the Winlogon + account, that starts `"C:\Program Files\mshell\mshell.exe" --shell`, and point the Winlogon Shell key at a do-nothing stub (or leave Explorer’s default). This gives mshell a full token without weakening UAC system-wide. More moving parts, but the safe way to get full control. @@ -167,42 +173,32 @@ The close keybind reaches them too. And your `init.lua` is never administrator-level code. -`install.bat` always installs `mshelld.exe` next to `mshell.exe` in `C:\mshell`, -and always replaces it in step with the shell — the two shake hands on a -protocol version and refuse a mismatch, so upgrading one without the other is a -supported way to break the helper. Installing the file is not the same as -running it, though: that needs a logon task with administrator rights, so it -happens whenever `install.bat` runs **from an administrator prompt**: - -``` -install.bat -``` - -Run elevated, it creates the task below and starts the helper straight away -rather than making you sign out. Run unelevated, it installs everything else, -leaves the task alone and says how to add it later. On upgrades, once the task -exists, `install.bat` restarts the helper so the build you just installed is the -one running (unelevated, the new build takes over at your next sign-in). -`install.bat /nohelper` skips registering the task; `/helper` still works and -insists on an administrator prompt. +The MSI always installs `mshelld.exe` next to `mshell.exe`, and always replaces +it in step with the shell — the two shake hands on a protocol version and +refuse a mismatch, so upgrading one without the other is a supported way to +break the helper. Installing the file is not the same as running it, though: +that needs a logon task with administrator rights, which is what the +**Privileged helper** option registers. It starts the helper straight away +rather than making you sign out, and restarts it on every upgrade so the build +you just installed is the one running. Turning the option off (or uninstalling) +removes the task. If you would rather do it by hand, this is the same command. A logon task is what gives the helper a full token without weakening UAC: ``` -schtasks /create /tn "mshelld" /tr "C:\mshell\mshelld.exe" ^ - /sc onlogon /rl highest /f +schtasks /create /tn "mshelld" /tr "\"C:\Program Files\mshell\mshelld.exe\"" ^ + /sc onlogon /rl highest /ru %USERNAME% /it /f ``` mshell finds it automatically; nothing needs configuring. If it is not running, mshell behaves exactly as it always has — those windows float and stay visible -on every desktop — so leaving it out (`/nohelper`, or an unelevated install) is -safe. `%TEMP%\mshelld.log` is the helper's own log if you need to see whether it +on every desktop — so leaving the option off is safe. `%TEMP%\mshelld.log` is the helper's own log if you need to see whether it started. -**Do not add `/ru SYSTEM` to that task.** The helper's pipe is restricted to the -user it runs as, and the command above has no `/ru`, so it runs as *you* — -elevated, but still you, which is why your unelevated mshell can talk to it. +**Do not change `/ru` to `SYSTEM`.** The helper's pipe is restricted to the +user it runs as, and the command above names *you* — elevated, but still you, +which is why your unelevated mshell can talk to it. Running it as SYSTEM grants the pipe to SYSTEM and no shell will ever connect; it would also put the helper in session 0, where it cannot touch your windows at all. The log says which identity it granted on the line above `listening on`, so @@ -221,7 +217,7 @@ anything running as you that can relaunch that `mshell.exe` or inject into it gets the same reach, and on an unsigned build so can anything that can replace it. The boundary the helper draws is your login session, not administrator — so keep the install directory writable by administrators only, and that is the -whole of it. +whole of it. `C:\Program Files\mshell` is exactly that by default. **What it does not do:** keybinds still stop responding while an elevated window has *focus*. That needs the keyboard hook itself to be elevated, and the hook was @@ -275,8 +271,7 @@ suggestions: - **The binary must be Authenticode-signed by a certificate the machine trusts.** The PKI signature check is always enforced for a uiAccess process. - **It must live under a protected path** — `C:\Program Files\...` or - `C:\Windows\System32\...`. `C:\mshell` does **not** qualify, so the usual - install location has to change for this build. + `C:\Windows\System32\...`. The MSI's `C:\Program Files\mshell` qualifies. If either is not met, Windows refuses the flag — the process may launch without it, or fail to launch at all. Because being started as the Winlogon shell is an @@ -290,11 +285,12 @@ Building it: make UIACCESS=1 # embeds src/mshell.uiaccess.manifest instead of the default ``` -then sign `mshell.exe` and install it under `C:\Program Files\mshell\`. For a +then sign `mshell.exe` and put it in place of the installed one in +`C:\Program Files\mshell\`. For a personal machine you can create your own certificate, trust it machine-wide, sign with it, and delete the private key afterwards so nothing else can ever be signed as mshell; a public release needs a real code-signing certificate. The -default `make` build is unchanged and still boots unsigned from `C:\mshell`, so +default `make` build is unchanged and still boots unsigned, so nothing here is required — it is the maximal-control option for those who want it. @@ -304,22 +300,23 @@ mshell itself already eats every `Win`+* combo, Alt+Tab / Alt+Esc / Ctrl+Esc, strips title bars, and — baked into `mshell.exe` (`window.c`) — forces square corners and kills the per-window open/close/minimise animation on every window it manages. Everything else Windows adds lives in the registry, split -into three importable files by blast radius: +by blast radius. The installer's **Windows tweaks** option covers the first two +below; the `.reg` files are in `C:\Program Files\mshell\extras`: - **`harden.reg`** — keyboard shortcuts the low-level hook can't reach: Win+L lock, the Sticky / Filter / Toggle / High-Contrast / Mouse-Keys hotkeys, and - bare PrtScn → Snip. All per-user (HKCU), and `install.bat` already applies - them — as `mshell.exe --tweaks apply input`, one value at a time, which also - records what each was so the uninstall can put *your* value back rather than - Microsoft's default. The file is for applying the same set without mshell. + bare PrtScn → Snip. All per-user (HKCU), and the **Windows tweaks** option + applies them — as `mshell.exe --tweaks apply input`, one value at a time, + which also records what each was so the uninstall can put *your* value back + rather than Microsoft's default. The file is for applying the same set without + mshell. - **Two of them need an administrator prompt**, and they are the two under + **Two of them need administrator rights**, and they are the two under `HKCU\…\CurrentVersion\Policies\` — the blanket Win-key hotkey policy and Win+L. Windows ACLs that subtree read-only for the user who owns the hive, so - an ordinary per-user install applies the other nine and skips those, and says - so. Re-run `install.bat` elevated (or right-click → Merge `harden.reg` and - accept UAC) to pick them up. Undo: `harden-undo.reg`, or `mshell.exe - --tweaks revert input`. + the installer writes those two itself, with the rights it was given, and + removes them on uninstall. Undo by hand: `harden-undo.reg` (elevated), or + `mshell.exe --tweaks revert input`. - **The browser fix (`--tweaks apply apps`)** — Chromium decides for itself whether anyone can see each of its windows and stops drawing the ones it @@ -330,17 +327,17 @@ into three importable files by blast radius: minimise/restore were all measured against such a window and none brought a pixel back. - `install.bat` sets the policy that turns that decision off, for Chrome, - Chromium and Edge. It needs an **administrator prompt** (its key lives under - `HKCU\Software\Policies`, which Windows ACLs read-only for you), and without - one it is skipped and reported, like the two hardening policies above. From an - admin prompt at any time: + The **Windows tweaks** option sets the policy that turns that decision off, + for Chrome, Chromium and Edge. Its key lives under `HKCU\Software\Policies`, + which Windows ACLs read-only for you, so like the two hardening policies above + the installer writes it with administrator rights. From an admin prompt at any + time: ``` - C:\mshell\mshell.exe --tweaks apply apps + "C:\Program Files\mshell\mshell.exe" --tweaks apply apps ``` - No admin, or an Electron app (which has no policy at all)? Put the flag on the + An Electron app (which has no policy at all)? Put the flag on the launch instead — in your shortcut, your launcher, or the `app`/`startup` command in `init.lua`: @@ -387,7 +384,8 @@ even if mshell has grabbed the keyboard hook. From there: - **File → Run new task → `regedit`** — go to `HKCU\Software\Microsoft\Windows NT\CurrentVersion\Winlogon` and delete the `Shell` value to permanently revert. -- **File → Run new task → `cmd`**, then run `uninstall.bat`. +- **File → Run new task → `ms-settings:appsfeatures`** — *Apps* in Settings, + where *mshell → Uninstall* puts Explorer back for good. - If mshell is stuck, find `mshell.exe` in the Details tab and End Task; your Win key comes back. @@ -397,15 +395,23 @@ account (which still has Explorer) and fix things. ## Uninstall -Run **`uninstall.bat`**, or delete the `Shell` value under -`HKCU\...\Winlogon`. Sign out and back in — Explorer returns. +Uninstall **mshell** from *Settings → Apps* (`ms-settings:appsfeatures`, or +from the Explorer that `mshell.exe --msg panic` starts), or with the MSI you +installed from: + +``` +msiexec /x mshell-.msi +``` + +That removes the `Shell` value, the `mshelld` logon task, the companion apps +and the installer's tweaks — putting your previous values back — and leaves +`%APPDATA%\mshell` (your config) in place; delete it too if you want mshell +fully gone. Sign out and back in — Explorer returns. -`uninstall.bat` also removes the `mshelld` logon task, so nothing keeps starting -an elevated binary out of `C:\mshell` after you delete that folder. That step -needs an administrator prompt — the task was created with `/rl highest` — and -the script tells you if it could not do it: +If the uninstall ran from another account, remove the leftovers by hand: ``` +reg delete "HKCU\Software\Microsoft\Windows NT\CurrentVersion\Winlogon" /v Shell /f schtasks /delete /tn "mshelld" /f ``` diff --git a/MANUAL-TESTS.md b/MANUAL-TESTS.md index d79c66c..1307438 100644 --- a/MANUAL-TESTS.md +++ b/MANUAL-TESTS.md @@ -34,7 +34,7 @@ With `mode = "top_bar"` (the default): rather than underneath it. - The desktop list updates as desktops are created and destroyed; the current one is both coloured and marked with `*`. -- The layout indicator follows `Win+Space`; the title follows the focus; the +- The layout indicator follows `Win+Shift+Space`; the title follows the focus; the clock advances. - A **fullscreen** window covers the bar. A **floating** window does not. - `position = "bottom"` moves it and the reserved strip together. @@ -200,7 +200,7 @@ With `mshelld.exe` running: - With Task Manager hidden on a background desktop, quit mshell (`Win+Shift+Q`): it is visible afterwards — an elevated window must not be stranded off-screen on exit. -- Focus Task Manager and press the close binding (`Win+Shift+c`): it closes. +- Focus Task Manager and press the close binding (`Win+q`): it closes. - Same three with an *admin* terminal or regedit, and with an app run explicitly as administrator (right-click → Run as administrator). @@ -260,31 +260,66 @@ belongs to a desktop instead of sitting on all of them. The mismatch case above is the one the installer exists to prevent, so check that the pair really does move together. -- **Plain `install.bat` unelevated, no task yet**: `C:\mshell\mshelld.exe` - exists afterwards, no `mshelld` task is registered (`schtasks /query /tn - mshelld` finds nothing), the closing summary says to re-run it as - administrator, and the exit code is 0 (the in-app updater depends on that). -- **Plain `install.bat` as administrator, no task yet**: the task is - registered, the helper is running immediately (no sign-out), and Task Manager - tiles. -- **`install.bat /nohelper` as administrator, no task yet**: no task is - registered, one line says so, and there is no "re-run as administrator" hint. -- **`install.bat /helper` unelevated**: refuses with the "needs an administrator - prompt" message, and `mshelld.exe` is still installed. -- **In-app update (`Win+Shift+U`) with no task**: the update succeeds; the - unelevated installer run it does registers nothing and prompts for nothing. -- **Upgrade with the helper already running**: re-run plain `install.bat` (no - flag) from a build with a different `MSHELLD_PROTO_VERSION`. Both binaries are - replaced, the helper is restarted, and mshell connects — no handshake failure - in `%TEMP%\mshell.log`. This is the case that silently broke before: the - singleton mutex means a helper that fails to stop leaves the *old* build - serving, so confirm the running `mshelld.exe` is the new one. -- **Upgrade unelevated with the helper running**: the copy is staged - (`mshelld.exe.old` appears), the script says the new helper takes over at the - next sign-in, and it does. -- **`uninstall.bat` as administrator**: the `mshelld` task is gone and the - helper is not running. Unelevated, it says so and prints the `schtasks - /delete` command instead of failing. +- **MSI with Privileged helper on, no task yet**: the task is registered + (`schtasks /query /tn mshelld /v` shows *Run As User* = you, not SYSTEM), the + helper is running immediately (no sign-out), and Task Manager tiles. +- **MSI with Privileged helper off**: no `mshelld` task is registered, but + `C:\Program Files\mshell\mshelld.exe` is still installed. +- **Modify → turn the helper off**: the task is deleted and `mshelld.exe` is + not running. +- **Upgrade with the helper already running**: run an MSI from a build with a + different `MSHELLD_PROTO_VERSION`. Both binaries are replaced, the helper is + restarted, and mshell connects — no handshake failure in `%TEMP%\mshell.log`. + The singleton mutex means a helper that fails to stop leaves the *old* build + serving, so confirm the running `mshelld.exe` is the new one and that + `mshelld.exe.old` is gone. +- **Uninstall**: the `mshelld` task is gone and the helper is not running. + +### The installer + +- **Fresh install, everything on**: *Custom Setup* lists all seven options. + Afterwards `C:\Program Files\mshell` holds `mshell.exe`, `mshelld.exe`, + `config\`, `meta\`, `extras\` and `mrun\`, `mnotify\`, `mcapture\`; HKCU + `Winlogon\Shell` is `"C:\Program Files\mshell\mshell.exe" --shell`; the + three App Paths keys exist; a *new* terminal finds `mshell`, `mrun` and + `mnotify` on `PATH`. +- **Shell off + "Try mshell now in test mode"**: mshell starts alongside + Explorer after *Finish*, and `Shell` is not set. +- **Tweaks on**: `HKCU\…\Policies\Explorer\NoWinKeys` = 1, + `…\Policies\System\DisableLockWorkstation` = 1, the three + `NativeWindowOcclusionEnabled` policies = 0, and `HKCU\Software\mshell\ + TweakBackup` records the previous values. Uninstall puts them back. +- **Companions off**: install with only `Core,Shell`. Sign in: no launch + failures in the log, `Win+Space` opens the built-in launcher, `Win+Shift+s` + opens Windows' snipping overlay, `Win+n` says mnotify is not installed. +- **Companions on**: sign in: mnotify and `mrun --daemon` are running, + `Win+Space` opens mrun instantly, `Win+n` opens notification history. +- **Manual upgrade while it is your shell**: run a newer MSI from the running + session. No "files in use" reboot is requested, `*.old` copies appear, and on + *Finish* mshell hands over to the new build (log banner names the new + version) and mnotify/mrun come back. A "files in use" page listing mshell may + appear first: pick the option that does not close it. +- **Failed upgrade rolls back**: cancel the upgrade at the progress page. The + old `mshell.exe` is back in place (not left as `mshell.exe.old`) and the next + sign-in still starts mshell. +- **Over-the-shoulder UAC** (a standard user typing an admin's password): the + closing text is the only warning — check the *admin's* `Shell` was set, which + is what INSTALL.md describes. +- **First start**: with no `%APPDATA%\mshell` folder, start mshell. The + default `init.lua` is copied there and a welcome notification explains the + leader menu. Restart: no second notification. +- **New default on upgrade**: edit `%APPDATA%\mshell\init.lua`, then install a + build whose shipped `init.lua` differs. Yours is unchanged, `init.lua.new` + holds the new default, and one notification names it. Delete + `init.lua.new` and restart: it does not come back. With an unedited + `init.lua`, the same upgrade writes no `init.lua.new`. +- **Signed release**: on a build made with the signing secrets, *Properties → + Digital Signatures* shows the certificate on the MSI, `mshell.exe` and + `mshelld.exe`, and the log no longer says `mshelld.exe is not + Authenticode-signed`. +- **winget**: `winget install --manifest \manifests\b\ + blendonl\mshell\` installs the release, and `winget list mshell` + shows it with the right version. ## DPI @@ -433,21 +468,22 @@ labels come out right. `--msg window.close` both close the focused window; `--msg "desktop.focus web"` and `--msg "switch_desktop web"` both switch. - **A count still repeats the right things.** `3` then `Win+j` moves the focus - three windows; `3` then `Win+Shift+q` quits once, not three times. + three windows; `3` then `Win+Shift+r` reloads once, not three times. ### Editor types -- After `install.bat`, `%APPDATA%\mshell\meta\mshell.lua`, - `meta\types.lua` and `.luarc.json` are all present. +- Delete `%APPDATA%\mshell` and start mshell: `init.lua`, `.luarc.json`, + `meta\mshell.lua` and `meta\types.lua` are all copied there, and the log + says `config: installed the default …` for the first two. - Open `%APPDATA%\mshell` in an editor with lua-language-server: typing `mshell.win` completes to `mshell.window`, hovering `mshell.window.close` shows its documentation, and `mshell.window.nope` is underlined. -- A reinstall refreshes `meta\`, but does **not** overwrite a `.luarc.json` - you have edited. -- Press `Win+Space` once and wait a second: the new layout **stays**. Then hold +- Upgrading refreshes `meta\` on the next start, but does **not** overwrite an + `init.lua` or `.luarc.json` you have edited. +- Press `Win+Shift+Space` once and wait a second: the new layout **stays**. Then hold it so autorepeat cycles through every layout, and release: the layout you released on stays. The log must NOT show `config: file changed on disk` after a - `Win+Space`; it must show it after actually saving `init.lua`. + `Win+Shift+Space`; it must show it after actually saving `init.lua`. ## The start desktop (`default`) @@ -601,7 +637,7 @@ labels come out right. - Switch to `tiling` and back to `bsp`: the dynamic layout works normally in between and the tree is rebuilt on return. - Move a window to another desktop while in bsp — it leaves the tree cleanly. -- `Win+Space` cycles the seven dynamic layouts and never lands in bsp; from bsp +- `Win+Shift+Space` cycles the seven dynamic layouts and never lands in bsp; from bsp it cycles OUT, to tiling. `layout.bsp` (`b b`) is the only way in. With **two monitors**, the desktop spanning both: @@ -666,7 +702,7 @@ and whether it is still readable. - `set_animation(120)`: windows glide to their new tiles rather than jumping. - During the motion, confirm windows are NOT snapped back — the drift detector must not fight the animation. -- Press `Win+Space` **twice in quick succession**, while the first move is still +- Press `Win+Shift+Space` **twice in quick succession**, while the first move is still in flight. The windows re-aim from where they are; they must not jump back to where the first move started, and the shell must stay responsive. - `set_animation(0)` restores instant placement. @@ -697,7 +733,7 @@ its cell — Discord, Steam and Spotify all have a minimum size — so open one those, not four terminals. - Open the stubborn app plus three or four other windows on one desktop, then - cycle layouts with `Win+Space` through all of them, and again with + cycle layouts with `Win+Shift+Space` through all of them, and again with `set_animation(120)` on. mshell, the bar and the other windows keep answering throughout; the mouse does not stutter. - The stubborn window ends up wherever it can fit and **stays there** — mshell @@ -951,7 +987,7 @@ Chrome or Edge, VS Code, Discord or Spotify (Electron), and something WPF. - Monocle: with three windows, cycle focus repeatedly. Each one is fully drawn when it comes up. Then switch to another desktop and back — the same single window is showing, and no other window flashed on the way. -- Leave monocle for `tiling` (`Win+Space`). All three windows are visible; none +- Leave monocle for `tiling` (`Win+Shift+Space`). All three windows are visible; none stayed hidden. - Close Discord to the tray **while on another desktop**, then click its tray icon from that other desktop. Its window does **not** appear over the desktop @@ -965,8 +1001,8 @@ Chrome or Edge, VS Code, Discord or Spotify (Electron), and something WPF. - `mshell.window.policy.hide("hide")`, save, then switch desktops. Desktops still work. Windows may flicker and a GPU-heavy app may briefly blank — that is the mechanism, and it is why `"cloak"` is the default. -- Still on `"hide"`, with two windows on one desktop: `Win+Space` into monocle, - `Win+Space` again to leave it. The second window comes back. Ten times over, +- Still on `"hide"`, with two windows on one desktop: `Win+Shift+Space` into monocle, + `Win+Shift+Space` again to leave it. The second window comes back. Ten times over, switching desktops away and back in between. Then read the log: an `app hid its own window` line naming a window *mshell* hid is the bug — our own hide, delivered late and mistaken for a minimise-to-tray — and the window it names @@ -1014,8 +1050,8 @@ level. ## The `config.update` action `make test` covers the version comparison and the release-JSON reading -(`test_update_parse`). What it cannot cover is the network, the unpack, and the -hand-off to `install.bat` — after which mshell restarts itself, so the +(`test_update_parse`). What it cannot cover is the network, `msiexec`, and the +hand-off to the new build — after which mshell restarts itself, so the shell-mode rows below need a real install rather than `--test`. Bind it if your config has not: `mshell.keys.bind({mod, shft}, "u", "config.update")`. @@ -1024,11 +1060,12 @@ Bind it if your config has not: `mshell.keys.bind({mod, shft}, "u", "config.upda |---|------|----------| | 1 | Press it while already on the latest release. | One notification: "mshell *x.y.z* is the latest release." Nothing is downloaded and nothing restarts. | | 2 | Press it with no network (disable the adapter). | "Could not reach GitHub to check for updates." No crash, no partial download left in `%TEMP%\mshell-update`. | +| 2b | During an update, answer **No** at the UAC prompt. | "Update to mshell *x.y.z* cancelled." mshell keeps running on the old build. | | 3 | Press it twice in quick succession, or hold the key down. | Only one run happens; the log says `update: already in progress`. | -| 4 | Press it from a **portable** copy (`--test`, or an exe outside the installed location). | It downloads, verifies and unpacks, then declines: a warning naming the unpacked folder, and `install.bat` is **not** run. Your shell is unchanged. | +| 4 | Press it from `--test` (not the registered shell). | It finds the release, then declines with a warning to run the MSI yourself; nothing is downloaded and `msiexec` is **not** run. Your shell is unchanged. | | 5 | Temporarily edit the version in `Makefile` down (e.g. to `0.0.1`), rebuild, install, and press it. | It reports the real latest release as available and proceeds — this is how to exercise the whole path without waiting for a release. | -| 6 | During #5, watch `%LOCALAPPDATA%\mshell\mshell.log`. | `update: sha256 verified (…)` appears before anything is unpacked. | -| 7 | Corrupt the check: with a proxy or by pointing `UPDATE_URL` at a release whose digest will not match, run it. | "The download does not match the hash GitHub published. Nothing was installed." Nothing is unpacked. | +| 6 | During #5, watch `%LOCALAPPDATA%\mshell\mshell.log`. | `update: sha256 verified (…)` appears before `msiexec` starts. | +| 7 | Corrupt the check: with a proxy or by pointing `UPDATE_URL` at a release whose digest will not match, run it. | "The download does not match the hash GitHub published. Nothing was installed." `msiexec` is not run. | **Shell-mode only** (needs a real install, Task Manager ready): @@ -1039,15 +1076,14 @@ Bind it if your config has not: `mshell.keys.bind({mod, shft}, "u", "config.upda banner**: `=== mshell vX.Y.Z starting ===` naming the version just installed. That line is the only proof the restart happened — an update that copies the exe and leaves the old process running looks identical from the desktop, and - is the bug this path was rewritten to make impossible. `C:\mshell\ - mshell.exe.old` should be gone afterwards, deleted by the new instance. + is the bug this path was rewritten to make impossible. `C:\Program Files\ + mshell\mshell.exe.old` should be gone afterwards, deleted by the new instance. Windows open beforehand are all still there and visible. -- Read `%LOCALAPPDATA%\mshell\install.log` after it: the whole of install.bat's - output is there, ending in "The caller asked to restart mshell itself". -- Make the install fail (make `C:\mshell` read-only, or delete `harden.reg` - from the unpacked folder before the copy) and run it. The toast names the - exit code and the log file, and mshell keeps running on the old build — no - restart into a half-installed tree. +- Read `%LOCALAPPDATA%\mshell\install.log` after it: it is the verbose MSI log, + ending in `Installation success or error status: 0`. +- Make the install fail (run a second `msiexec` so the first finds the installer + busy) and run it. The toast names the exit code and the log file, and mshell + keeps running on the old build — no restart into a half-installed tree. - Do the same with `AutoRestartShell` set to `0`. mshell installs, then says so and does **not** exit — quitting would log the session out. The new build is there for the next sign-in. @@ -1062,9 +1098,9 @@ These cannot be tested with `--test` and need a real install. Have Task Manager - Lock and unlock: no modifier is stuck afterwards (press a bare letter key and confirm it types rather than firing a `Win+` binding). - Trigger a UAC prompt: same check on return. -- Run `install.bat` over a running install: it replaces the exe and restarts. -- `uninstall.bat`, sign out and in: Explorer returns, and the foreground-lock - timeout is back to its previous value. +- Run the MSI over a running install: it replaces the exe and restarts. +- Uninstall from *Apps*, sign out and in: Explorer returns, and the + foreground-lock timeout is back to its previous value. - Run a fullscreen game: keybinds keep responding while it has focus. ## Restarting over a live session diff --git a/Makefile b/Makefile index 8f44390..859cf06 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ CC = x86_64-w64-mingw32-gcc WINDRES = x86_64-w64-mingw32-windres OBJCOPY = x86_64-w64-mingw32-objcopy -VERSION = 0.15.25 +VERSION = 0.16.0 VER_MAJOR := $(word 1,$(subst ., ,$(VERSION))) VER_MINOR := $(word 2,$(subst ., ,$(VERSION))) @@ -150,15 +150,15 @@ HELPER_LDLIBS = -luser32 -ladvapi32 -lwintrust -lcrypt32 SYMBOLS = $(TARGET).debug $(HELPER).debug DISTNAME = mshell-$(VERSION)-win64 -DISTDIR = dist/$(DISTNAME) SYMNAME = $(DISTNAME)-symbols SYMDIR = dist/$(SYMNAME) -DIST_FILES = install.bat uninstall.bat \ - harden.reg harden-undo.reg \ - debloat.reg debloat-undo.reg \ - services.reg services-undo.reg \ - INSTALL.md README.md CHANGELOG.md MANUAL-TESTS.md \ - LICENSE THIRD-PARTY-NOTICES.md +STAGEDIR = dist/stage +MSI = dist/mshell-$(VERSION).msi +EXTRA_FILES = harden.reg harden-undo.reg \ + debloat.reg debloat-undo.reg \ + services.reg services-undo.reg +DOC_FILES = INSTALL.md README.md CHANGELOG.md \ + LICENSE THIRD-PARTY-NOTICES.md CPPCHECK = cppcheck CPPCHECK_DIR = .github/cppcheck @@ -179,7 +179,7 @@ ASAN_CFLAGS = -O1 -g -Wall -Wextra \ -fno-sanitize-recover=all TEST_BINS = $(TEST_MODULES:%=$(TEST_DIR)/test_%$(TEST_SUFFIX)) -.PHONY: all clean check-lua dist test test-asan run-host-tests regs msi \ +.PHONY: all clean check-lua dist stage test test-asan run-host-tests regs msi \ print-version probe meta check-config cppcheck symbols all: check-lua $(TARGET) $(HELPER) @@ -279,31 +279,32 @@ regs: $(TARGET) echo " SKIP regs — wine not installed (the .reg files are checked in)"; \ fi -msi: $(TARGET) $(HELPER) - @if command -v wixl >/dev/null 2>&1; then \ - echo " MSI dist/mshell-$(VERSION).msi"; \ - mkdir -p dist; \ - wixl -D Version=$(VERSION) -o "dist/mshell-$(VERSION).msi" \ - packaging/mshell.wxs; \ +stage: $(TARGET) $(HELPER) + @echo " STAGE $(STAGEDIR)" + rm -rf "$(STAGEDIR)" + mkdir -p "$(STAGEDIR)/config" "$(STAGEDIR)/meta" \ + "$(STAGEDIR)/extras" "$(STAGEDIR)/docs" + cp $(TARGET) $(HELPER) "$(STAGEDIR)/" + cp config/init.lua config/.luarc.json "$(STAGEDIR)/config/" + cp meta/mshell.lua meta/types.lua "$(STAGEDIR)/meta/" + cp $(EXTRA_FILES) "$(STAGEDIR)/extras/" + cp $(DOC_FILES) "$(STAGEDIR)/docs/" + +msi: stage + @if command -v pwsh >/dev/null 2>&1 && command -v wix >/dev/null 2>&1; then \ + pwsh -NoProfile -File packaging/build-msi.ps1 \ + -Version $(VERSION) -Stage "$(STAGEDIR)" -Out "$(MSI)"; \ else \ - echo " SKIP msi — wixl not installed (apt install wixl)"; \ + echo " SKIP msi — needs pwsh and the WiX v5 'wix' tool (CI builds it on Windows)"; \ fi -dist: $(TARGET) $(HELPER) $(SYMBOLS) - @echo " DIST $(DISTNAME)" - rm -rf "$(DISTDIR)" "dist/$(DISTNAME).zip" "$(SYMDIR)" "dist/$(SYMNAME).zip" - mkdir -p "$(DISTDIR)/config" "$(DISTDIR)/meta" "$(SYMDIR)" - cp $(TARGET) "$(DISTDIR)/" - cp $(HELPER) "$(DISTDIR)/" - cp $(SYMBOLS) "$(SYMDIR)/" - cp config/init.lua "$(DISTDIR)/config/" - cp meta/mshell.lua "$(DISTDIR)/meta/" - cp meta/types.lua "$(DISTDIR)/meta/" - cp config/.luarc.json "$(DISTDIR)/config/" - cp $(DIST_FILES) "$(DISTDIR)/" - cd dist && python3 -m zipfile -c "$(DISTNAME).zip" "$(DISTNAME)" +dist: stage $(SYMBOLS) + @echo " DIST $(SYMNAME)" + rm -rf "$(SYMDIR)" "dist/$(SYMNAME).zip" + mkdir -p "$(SYMDIR)" + cp $(SYMBOLS) "$(SYMDIR)/" cd dist && python3 -m zipfile -c "$(SYMNAME).zip" "$(SYMNAME)" - @echo " -> dist/$(DISTNAME).zip" + @echo " -> $(STAGEDIR)/" @echo " -> dist/$(SYMNAME).zip" $(TEST_DIR)/test_%$(TEST_SUFFIX): $(TEST_DIR)/test_%.c $(SRC_DIR)/%.c $(SRC_DIR)/%.h diff --git a/README.md b/README.md index 0d692a8..402765c 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,8 @@ tiled, driven entirely from the keyboard and configured in Lua. - **An optional privileged helper** (`mshelld.exe`) so an *unelevated* mshell can still tile, hide and close windows owned by elevated processes — without your `init.lua` ever becoming administrator-level code. No config, no Lua, no - scripting: it moves, restacks and closes windows, and nothing else. Installed by - `install.bat`, and registered to start at sign-in whenever `install.bat` runs - from an administrator prompt (`/nohelper` skips it). + scripting: it moves, restacks and closes windows, and nothing else. Installed + and registered to start at sign-in by the MSI's *Privileged helper* option. - **A built-in launcher.** `launcher` types a name and runs a program. If you want modules, Lua configuration and a clipboard/emoji story, [**mrun**](https://github.com/blendonl/mrun) is a separate app that does that. @@ -176,11 +175,17 @@ tar xzf lua-5.4.7.tar.gz --strip-components=1 cd ../.. make # produces mshell.exe make test # runs the host-side unit tests (no Windows needed) -make dist # produces dist/mshell--win64.zip +make dist # stages the installer's payload in dist/stage, plus a symbols zip ``` -The version lives in one place — `VERSION` in the `Makefile` — and is baked into -the binary's startup log, its VERSIONINFO resource, and the release zip name. +The installer itself, `mshell-.msi`, is built on Windows by +`packaging/build-msi.ps1` with WiX v5 — CI does it on every push, bundling the +mrun, mnotify and mcapture releases pinned in `packaging/companions.json` (bump +a version and its `sha256` there to ship a newer one). When the +`MSHELL_SIGN_PFX_BASE64` and `MSHELL_SIGN_PFX_PASSWORD` secrets hold a +code-signing certificate, the binaries and the MSI are signed. The version lives in one place — +`VERSION` in the `Makefile` — and is baked into the binary's startup log, its +VERSIONINFO resource, and the MSI. `make test` builds the parts with no Windows in them — rule pattern matching and the tiling arithmetic — with the host compiler and runs them directly, so it @@ -189,8 +194,9 @@ machine is listed in [MANUAL-TESTS.md](MANUAL-TESTS.md). ## Try it (without committing) -Copy `mshell.exe` and `config/` to the Windows machine and run alongside -Explorer — quitting just exits, it does **not** log you out: +Install the MSI with **Use mshell as my Windows shell** turned off, then tick +*Try mshell now in test mode* on the last page — or run it alongside Explorer +yourself any time. Quitting just exits, it does **not** log you out: ``` mshell.exe --test @@ -212,26 +218,35 @@ helper writes `mshelld.log` beside it. ## Install as the shell -See [INSTALL.md](INSTALL.md). In short: `install.bat` copies the program to -`C:\mshell`, your config to `%APPDATA%\mshell\init.lua`, and sets the -**per-user** (HKCU) Winlogon `Shell` key. It only affects your account and is -reverted by `uninstall.bat`. +See [INSTALL.md](INSTALL.md). In short: run `mshell-.msi`. It installs +to `C:\Program Files\mshell`, sets the **per-user** (HKCU) Winlogon `Shell` key +for the account that runs it, and offers the privileged helper, the Windows +tweaks and the companion apps — mrun, mnotify and mcapture — as checkboxes. +mshell copies the default config to `%APPDATA%\mshell\init.lua` the first time +it starts without one. Uninstalling from *Apps* puts Explorer back. -Run it again to upgrade: it replaces the installed exe even while that exe is -your running shell, then restarts mshell so the new build takes over without a -sign-out. Your `init.lua` is never overwritten. +Run a newer MSI (or press `Win+Shift+u`) to upgrade: it replaces the installed +exe even while that exe is your running shell, then restarts mshell so the new +build takes over without a sign-out. Your `init.lua` is never overwritten. ## The default config -The release ships one config, `config/init.lua`. It is small, assumes nothing -is installed but Windows, and opens `cmd.exe` because that is the one terminal -every machine has. `install.bat` puts it at `%APPDATA%\mshell\init.lua` only -when you have no config there yet, and a portable copy with no config falls back -to it beside `mshell.exe`. +The release ships one config, `config/init.lua`, and mshell copies it to +`%APPDATA%\mshell\init.lua` the first time it starts without a config there. +It is written to work whichever installer options you picked: -It launches nothing at startup: a default that opened a browser, a chat client -and a game would greet most new users with a log full of launch failures. It is -a starting point to make your own, not someone else's setup. +- **Terminal** is the first of `wt.exe`, `pwsh.exe` and `cmd.exe` that launches. +- **Launcher** (`Win+Space`) is mrun when it is installed, kept resident with + `--daemon` so it opens instantly, and mshell's built-in launcher otherwise. +- **Notifications and tray** — mnotify is started with the session when it is + installed; `Win+n` opens its history and `Win+Shift+n` the tray icons. +- **Screenshots** (`Win+Shift+s`, `Win+PrintScreen`) use mcapture when it is + installed and Windows' own snipping overlay otherwise. + +It looks for each companion under `C:\Program Files\mshell\` (where the +MSI puts it) and `%LOCALAPPDATA%\Programs\` (where its own installer +does). Nothing else is launched at startup — the config is a starting point to +make your own, not someone else's setup. ## The launcher @@ -256,28 +271,47 @@ mshell.keys.bind({"LWin"}, "Space", ## Default keybindings Modifier is **`Win`** (swallowed entirely; every `Win+*` combo is mshell's). -Two ways to drive it: hold **`Win`+key** for a chord, or **tap `Win`** on its own -to enter the leader map and use bare keys — `h/j/k/l` focus, `Space` cycles the -layout, `t` opens a terminal. Tap `Win` again or press `Esc` to leave. +Two ways to drive it: + +- **Hold `Win`** and press a key for a chord. Focus and movement (`h/j/k/l`) + live only here, so a stray letter never moves your focus. +- **Tap `Win`** on its own for the leader menu. The which-key panel lists what + is next; every entry runs once and drops you back, except `resize` and + `media`, which stay until `Esc` or another `Win` tap. Desktops are created by going to them and destroyed when you leave them empty, so none of the numbered desktops has to exist in advance. -| Keys | Action | +| Hold `Win` + | Action | +|------|--------| +| `h/j/k/l` · `Shift+h/j/k/l` | Focus · move the window that way | +| `Ctrl+h/l` · `Ctrl+j/k` | Master ratio · size within the stack (cfact) | +| `1..9` · `Shift+1..9` · `Ctrl+1..9` | Go to desktop · send the window there · take it there | +| `Tab` · `[` · `]` | Last desktop · previous · next | +| `,` `.` (+`Shift`) | Focus / move to the previous or next monitor | +| `Return` · `Space` | Terminal · launcher | +| `Shift+Return` · `Shift+Space` | Swap with master · cycle layout | +| `t` · `m` | Tiling · monocle | +| `q` · `Shift+q` | Close · kill | +| `f` · `Shift+f` · `Ctrl+f` · `F11` | Float · fullscreen window · inside the tile · both | +| `` ` `` · `Shift+` `` ` `` | Scratchpad toggle · mark | +| `b` | Hide / show the bar | +| `Shift+s` · `PrintScreen` | Screenshot region · screen | +| `n` · `Shift+n` | Notification history · tray icons | +| `Shift+r` · `Shift+u` | Reload config · install the latest release | + +| Tap `Win`, then | Submap | |------|--------| -| `Win+h/j/k/l` | Focus left/down/up/right | -| `Win+Shift+h/j/k/l` | Move the window that way | -| `Win+1..9` / `Win+Shift+1..9` | Go to desktop `1`..`9` / send the window there | -| ``Win+` `` · `Win+[` · `Win+]` | Last desktop · previous · next | -| `Win+,` `.` (+`Shift`) | Focus / move to the previous or next monitor | -| `Win+t` `m` `g` · `Win+Space` | Layout tiling / monocle / grid · cycle | -| `Win+f` · `Win+Return` | Toggle floating · promote to master | -| `Win+Ctrl+h/l` · `Win+Ctrl+j/k` | Master ratio · master count | -| `Win+Shift+f` · `Win+Ctrl+f` · `Win+F11` | Fullscreen: window · inside the tile · both | -| `Win+Shift+c` · `Win+Shift+x` | Close · kill | -| `Win+Shift+Return` | Terminal | -| `Win+Shift+r` · `Win+Shift+q` | Reload config · quit | -| `Win+Shift+u` | Install the latest GitHub release (mshell restarts) | +| `g` | **go** — `1..9` desktop, `Tab` last, `[` `]` previous/next, `,` `.` monitor | +| `m` | **send** — `1..9` send the window to a desktop, `,` `.` to a monitor | +| `w` | **window** — `q` close, `k` kill, `f` float, `c` center, `s` sticky, `t` on top, `z` zoom, `p` promote, `w`/`i`/`a` fullscreen, `m` mark scratchpad, `u` urgent | +| `l` | **layout** — `t` tiling, `m` monocle, `g` grid, `s` spiral, `c` centered, `b` bstack, `o` columns, `Space` cycle, `=` `-` master count | +| `r` | **resize** (stays) — `h/l` master ratio, `j/k` cfact, `r` reset, arrows grow a floating window | +| `a` | **apps** — `t` terminal, `r` launcher, `s`/`w`/`f` screenshot, `n` notifications, `y` tray | +| `v` | **media** (stays) — `k/j` volume, `m` mute, `p` play, `h/l` previous/next | +| `s` | **session** — `l` lock, `s` sleep, `h` hibernate, `o` log off, `r` reboot, `p` shut down | +| `x` | **mshell** — `r` reload, `u` update, `s` restart, `h` restart helper, `b`/`t`/`f` bar, `p` panic, `q` quit | +| `Space` · `Return` · `Tab` | Launcher · terminal · last desktop | ## Configuration @@ -331,7 +365,7 @@ end mshell ships the type definitions for its whole API, generated from the same table the binary dispatches through, so they describe exactly the release you -have installed. `install.bat` puts them in `%APPDATA%\mshell\meta` alongside a +have installed. mshell keeps them current in `%APPDATA%\mshell\meta` alongside a `.luarc.json` that points [lua-language-server][luals] at them. Open `%APPDATA%\mshell` in any editor with that LSP and you get completion over @@ -403,7 +437,7 @@ split wherever you were, `layout.split.h`/`layout.split.v` decide the direction takes, and any split can become a **tabbed** or **stacked** container showing one window at a time. The tree does not replace the window list — it is an index over it — so a desktop moves between `bsp` and the dynamic layouts freely. Each -display gets its own tree, and `Win+Space` deliberately does not cycle into +display gets its own tree, and `layout.cycle` deliberately does not cycle into `bsp`: a layout you build by hand is one you ask for, with `mshell.layout.bsp`. **A launcher.** `mshell.launcher.open` opens a filter over your Start-menu @@ -484,21 +518,23 @@ can, because not binding keys is the point. **Updating from a keybinding.** The `update` action — `Win+Shift+u` in the default config — fetches the latest GitHub release, checks the -download against the SHA-256 the release published, unpacks it and runs the -`install.bat` inside it. That script is the upgrade path either way: it renames -the running image rather than overwriting it and restarts mshell itself, so the -screen blinks and you come back on the new build. Progress, and every way it can -fail, arrives as a notification. +download against the SHA-256 the release published, and runs the `.msi` with +`msiexec /passive` — Windows asks for administrator approval once. The installer +renames the running image rather than overwriting it, keeps the options you +picked last time, and mshell then hands over to the new build, so the screen +blinks and you come back on it. Progress, and every way it can fail, arrives as +a notification; `%LOCALAPPDATA%\mshell\install.log` is the installer's own log. This is the deliberate opposite of `set_update_check`, which only ever *tells* you a release exists. An updater that swapped out the shell unattended would turn a bad release into a black screen at sign-in with no desktop left to fix it from; a key you pressed, while sitting in front of the machine, is a different proposition. Two things it will not do: run twice at once, and install when the -running mshell is not the registered shell — from a portable copy or `--test`, -`install.bat` would not be upgrading anything, it would be taking over your -shell for the first time. In that case it unpacks and tells you where, and you -run `install.bat` yourself. `mshell.exe --msg update` works too. +running mshell is not the registered shell — from `--test`, the installer would +not be upgrading anything, it would be taking over your shell for the first +time. In that case it tells you so, and you run the MSI yourself. +`mshell.exe --msg update` works too, and `mshell.exe --msg restart` hands over +to whatever build is installed now. **Safe mode.** Three starts inside a minute means the previous two did not survive one, so the next run skips `init.lua` entirely and comes up on the @@ -563,7 +599,7 @@ once — it stays completely untouched. Naming a window in a rule also rescues it from the "caption-less popup = menu/tooltip" filter — the exact style a borderless-fullscreen game uses — so the game stays a real managed window: it hides on desktop switch and -`Win+Shift+c` can close it. Because games rebuild their window when the +`Win+q` can close it. Because games rebuild their window when the graphics device comes up (and again whenever you change resolution or flip windowed/borderless in their options), the frame and the fullscreen geometry are re-asserted whenever the window moves, not just once when it opens. diff --git a/config/init.lua b/config/init.lua index 1331263..2aa835a 100644 --- a/config/init.lua +++ b/config/init.lua @@ -1,10 +1,6 @@ --[[ mshell — default configuration. - Deliberately small, and it assumes nothing is installed but Windows itself. - Everything here works on a bare machine; change the terminal on the first - line below and you have your setup. - Location: %APPDATA%\mshell\init.lua Reload: save the file (auto-reload), or press Win+Shift+R. Safety: a syntax error keeps the PREVIOUS config running and writes the @@ -18,91 +14,95 @@ warning on anything mshell does not have. --]] --- The one thing worth changing first. cmd.exe is used because it is the only --- terminal guaranteed to exist on every Windows install; swap in --- "wt.exe", "alacritty.exe", "pwsh.exe", … once you know what you have. -local TERMINAL = "cmd.exe" - local mod, shft, ctrl = "LWin", "Shift", "Ctrl" ----------------------------------------------------------------------- --- Appearance ----------------------------------------------------------------------- -mshell.layout.gaps(6, 6) -- between windows, at the screen edge -mshell.appearance.border { -- focus ring plus a bar on one edge, because - width = 2, -- a 2px ring at the screen edge is easy to miss - focused = 0xffffff, -- 0xRRGGBB - accent = "bottom", -- "top" | "bottom" | "none" +local TERMINALS = { "wt.exe", "pwsh.exe", "cmd.exe" } + +local function find_app(name) + local roots = { + (os.getenv("ProgramFiles") or "C:\\Program Files") .. "\\mshell\\" .. name, + (os.getenv("LOCALAPPDATA") or "") .. "\\Programs\\" .. name, + } + for _, root in ipairs(roots) do + local path = root .. "\\" .. name .. ".exe" + local f = io.open(path, "rb") + if f then + f:close() + return path + end + end +end + +local MRUN = find_app("mrun") +local MNOTIFY = find_app("mnotify") +local MCAPTURE = find_app("mcapture") + +local function terminal() + for _, exe in ipairs(TERMINALS) do + if mshell.exec(exe) then return end + end +end + +local function launcher() + if not (MRUN and mshell.exec(MRUN)) then mshell.launcher.open() end +end + +local function capture(args) + return function() + if MCAPTURE then mshell.exec(MCAPTURE, args) else mshell.exec("ms-screenclip:") end + end +end + +local function notifications(args) + return function() + if MNOTIFY then + mshell.exec(MNOTIFY, args) + else + mshell.notify("mnotify is not installed — re-run the mshell installer to add it", "warn") + end + end +end + +local function go(name) return function() mshell.desktop.focus(name) end end +local function send(name) return function() mshell.window.move.to_desktop(name) end end +local function follow(name) + return function() + mshell.window.move.to_desktop(name) + mshell.desktop.focus(name) + end +end + +mshell.layout.gaps(6, 6) +mshell.layout.smart_gaps(true) +mshell.layout.set("tiling") +mshell.layout.master.ratio(0.55) + +mshell.appearance.border { + width = 2, + focused = 0x89b4fa, + accent = "bottom", accent_width = 3, } -mshell.appearance.background(0x1e1e2e) -- desktop backdrop (there is no wallpaper) -mshell.layout.set("tiling") -- tiling|monocle|grid|spiral|centered|bstack|columns -mshell.layout.master.ratio(0.60) - --- Status bar. There is no taskbar, and because desktops are created and --- destroyed as you use them, this is the only thing telling you which ones --- exist and which you are on. --- --- mode = "top_bar" is a strip on every monitor that reserves its space, so --- tiled windows sit below it while a fullscreen window still covers it. --- mode = "floating" is instead one panel in the middle of the focused monitor --- — big clock, date, desktops, and mshell's notifications listed inline — that --- floats over the windows and reserves nothing. Bind mshell.bar.toggle to get --- it out of the way. +mshell.appearance.background(0x1e1e2e) + mshell.bar.setup({ enabled = true, - mode = "top_bar", -- or "floating" - position = "top", -- or "bottom" (top_bar mode) - height = 28, -- scaled per monitor for DPI; sets the type scale + mode = "top_bar", + position = "top", + height = 28, bg = 0x1e1e2e, fg = 0xcdd6f4, - accent = 0x7aa2f7, -- the desktop you're on - dim = 0x6c7086, -- the others - -- "notifications" is floating-only: with it on, the panel is where - -- mshell's messages appear instead of as separate toasts. + accent = 0x89b4fa, + dim = 0x7f849c, modules = { "desktops", "layout", "title", "clock", "notifications" }, }) ----------------------------------------------------------------------- --- Window rules ----------------------------------------------------------------------- --- Float anything Windows considers a dialog — file pickers, message boxes, --- permission prompts. They carry no class or process of their own (a file --- picker IS the host app's process), so this is the only way to name them. mshell.window.rule({ dialog = true }, "float") - --- Credential and UAC prompts are their own processes and are not dialogs in --- the sense above, so they need naming. mshell.window.rule({ process = "consent.exe" }, "float") mshell.window.rule({ process = "CredentialUIBroker.exe" }, "float") +mshell.window.rule({ process = "Taskmgr.exe" }, "float") +mshell.window.rule({ process = "SnippingTool.exe" }, "float") ----------------------------------------------------------------------- --- Sub-maps ----------------------------------------------------------------------- --- Bar sub-map (one-shot): switch bar mode then back to root. -mshell.keys.submap("bar", { - t = mshell.bar.top, -- top-bar strip on every monitor - f = mshell.bar.floating, -- floating panel in the centre -}) - --- Extra sub-map (one-shot): Win+x opens it, then pick a category. A submap is --- entered by naming it, which is why "bar" is a plain string here. -mshell.keys.submap("extra", { - b = "bar", -- bar mode: Win+x b t / Win+x b f -}) - ----------------------------------------------------------------------- --- Keybindings — hold Win and press a key --- --- The third argument is the action itself, not its name: mshell.window.close --- rather than "close". Your editor completes them, and a typo is caught as you --- write it instead of when the config loads. --- --- Anything that needs telling WHAT to act on goes inside a function, which is --- why the desktop keys below are written the way they are. A function has no --- name for the which-key panel to show, so give those a desc. ----------------------------------------------------------------------- --- focus / move, vim keys mshell.keys.bind({mod}, "h", mshell.window.focus.left) mshell.keys.bind({mod}, "j", mshell.window.focus.down) mshell.keys.bind({mod}, "k", mshell.window.focus.up) @@ -111,96 +111,172 @@ mshell.keys.bind({mod, shft}, "h", mshell.window.move.left) mshell.keys.bind({mod, shft}, "j", mshell.window.move.down) mshell.keys.bind({mod, shft}, "k", mshell.window.move.up) mshell.keys.bind({mod, shft}, "l", mshell.window.move.right) +mshell.keys.bind({mod, ctrl}, "h", mshell.layout.master.ratio.shrink) +mshell.keys.bind({mod, ctrl}, "l", mshell.layout.master.ratio.grow) +mshell.keys.bind({mod, ctrl}, "j", mshell.layout.cfact.shrink) +mshell.keys.bind({mod, ctrl}, "k", mshell.layout.cfact.grow) --- desktops. These are NAMES, not slots: Win+3 goes to a desktop called "3", --- which is created when you go there and destroyed when you leave it empty. --- Any name works, whether or not it appears in this file. for i = 1, 9 do local name = tostring(i) - mshell.keys.bind({mod}, name, - function() mshell.desktop.focus(name) end, { desc = name }) - mshell.keys.bind({mod, shft}, name, - function() mshell.window.move.to_desktop(name) end, { desc = "→ " .. name }) + mshell.keys.bind({mod}, name, go(name), { desc = name }) + mshell.keys.bind({mod, shft}, name, send(name), { desc = "send → " .. name }) + mshell.keys.bind({mod, ctrl}, name, follow(name), { desc = "take → " .. name }) end -mshell.keys.bind({mod}, "`", mshell.desktop.focus.last) -- back where you came from -mshell.keys.bind({mod}, "]", mshell.desktop.focus.next) -- step through what exists now -mshell.keys.bind({mod}, "[", mshell.desktop.focus.prev) +mshell.keys.bind({mod}, "Tab", mshell.desktop.focus.last) +mshell.keys.bind({mod}, "]", mshell.desktop.focus.next) +mshell.keys.bind({mod}, "[", mshell.desktop.focus.prev) --- monitors mshell.keys.bind({mod}, ",", mshell.monitor.focus.prev) mshell.keys.bind({mod}, ".", mshell.monitor.focus.next) mshell.keys.bind({mod, shft}, ",", mshell.window.move.to_monitor.prev) mshell.keys.bind({mod, shft}, ".", mshell.window.move.to_monitor.next) --- layout -mshell.keys.bind({mod}, "t", mshell.layout.tiling) -mshell.keys.bind({mod}, "m", mshell.layout.monocle) -mshell.keys.bind({mod}, "g", mshell.layout.grid) -mshell.keys.bind({mod}, "Space", mshell.layout.cycle) -mshell.keys.bind({mod}, "f", mshell.window.float.toggle) -mshell.keys.bind({mod}, "Return",mshell.layout.master.promote) -mshell.keys.bind({mod}, "b", mshell.bar.toggle) -- hide/show the status bar -mshell.keys.bind({mod}, "x", "extra") -- Win+x b t/f: bar mode - --- master area: ratio (Ctrl+h/l), count (Ctrl+j/k) -mshell.keys.bind({mod, ctrl}, "h", mshell.layout.master.ratio.shrink) -mshell.keys.bind({mod, ctrl}, "l", mshell.layout.master.ratio.grow) -mshell.keys.bind({mod, ctrl}, "j", mshell.layout.master.count.dec) -mshell.keys.bind({mod, ctrl}, "k", mshell.layout.master.count.inc) - --- fullscreen. Three flavours, because the WINDOW and the app's own CONTENT --- fullscreen (YouTube's button, F11) are different things: -mshell.keys.bind({mod, shft}, "f", mshell.window.fullscreen.window) -- covers the monitor -mshell.keys.bind({mod, ctrl}, "f", mshell.window.fullscreen.content) -- stays in the tile -mshell.keys.bind({mod}, "F11", mshell.window.fullscreen.both) -- covers the display - --- windows. -mshell.keys.bind({mod, shft}, "c", mshell.window.close) -mshell.keys.bind({mod, shft}, "x", mshell.window.kill) -mshell.keys.bind({mod}, "s", mshell.window.sticky.toggle) -- to every desktop -mshell.keys.bind({mod}, "z", mshell.layout.master.zoom) -- swap with master - --- Scratchpad: one window you can summon anywhere and dismiss again. Mark a --- window once (Win+Shift+s), then Win+Ctrl+Space toggles it wherever you are. -mshell.keys.bind({mod, shft}, "s", mshell.window.scratchpad.mark) -mshell.keys.bind({mod, ctrl}, "Space", mshell.window.scratchpad.toggle) - --- programs -mshell.keys.bind({mod, shft}, "Return", - function() mshell.exec(TERMINAL) end, { desc = "terminal" }) - --- mshell itself -mshell.keys.bind({mod, shft}, "r", mshell.config.reload) -mshell.keys.bind({mod, shft}, "q", mshell.config.quit) -- as the shell, this logs you out +mshell.keys.bind({mod}, "Return", terminal, { desc = "terminal" }) +mshell.keys.bind({mod}, "Space", launcher, { desc = "launcher" }) +mshell.keys.bind({mod, shft}, "Return", mshell.layout.master.zoom) +mshell.keys.bind({mod, shft}, "Space", mshell.layout.cycle) + +mshell.keys.bind({mod}, "q", mshell.window.close) +mshell.keys.bind({mod, shft}, "q", mshell.window.kill) +mshell.keys.bind({mod}, "f", mshell.window.float.toggle) +mshell.keys.bind({mod, shft}, "f", mshell.window.fullscreen.window) +mshell.keys.bind({mod, ctrl}, "f", mshell.window.fullscreen.content) +mshell.keys.bind({mod}, "F11", mshell.window.fullscreen.both) +mshell.keys.bind({mod}, "t", mshell.layout.tiling) +mshell.keys.bind({mod}, "m", mshell.layout.monocle) +mshell.keys.bind({mod}, "b", mshell.bar.toggle) +mshell.keys.bind({mod}, "`", mshell.window.scratchpad.toggle) +mshell.keys.bind({mod, shft}, "`", mshell.window.scratchpad.mark) + +mshell.keys.bind({mod, shft}, "s", capture("--select"), { desc = "screenshot: region" }) +mshell.keys.bind({mod}, "PrintScreen", capture("--screen"), { desc = "screenshot: screen" }) +mshell.keys.bind({mod}, "n", notifications("--history"), { desc = "notifications" }) +mshell.keys.bind({mod, shft}, "n", notifications("--tray"), { desc = "tray icons" }) --- Install the latest GitHub release: downloads it, checks it against the hash --- GitHub published, and runs the install.bat inside it — which restarts mshell. +mshell.keys.bind({mod}, "VolumeUp", mshell.media.volume.up) +mshell.keys.bind({mod}, "VolumeDown", mshell.media.volume.down) +mshell.keys.bind({mod}, "VolumeMute", mshell.media.volume.mute) +mshell.keys.bind({mod}, "MediaPlay", mshell.media.play) +mshell.keys.bind({mod}, "MediaNext", mshell.media.next) +mshell.keys.bind({mod}, "MediaPrev", mshell.media.prev) + +mshell.keys.bind({mod, shft}, "r", mshell.config.reload) mshell.keys.bind({mod, shft}, "u", mshell.config.update) ----------------------------------------------------------------------- --- Leader mode — tap Win on its own, then press bare keys ----------------------------------------------------------------------- --- A submap is persist = true (stay until Esc) or one-shot (next key drops --- back). This one persists, so h/j/k/l can be pressed repeatedly. -mshell.keys.submap("normal", { - h = mshell.window.focus.left, - j = mshell.window.focus.down, - k = mshell.window.focus.up, - l = mshell.window.focus.right, - Space = mshell.layout.cycle, - Return = mshell.layout.master.promote, - f = mshell.window.float.toggle, - c = mshell.window.close, - t = { function() mshell.exec(TERMINAL) end, desc = "terminal" }, +local go_keys, send_keys = {}, {} +for i = 1, 9 do + local name = tostring(i) + go_keys[name] = { go(name), desc = name } + send_keys[name] = { send(name), desc = "→ " .. name } +end +go_keys.Tab = mshell.desktop.focus.last +go_keys["]"] = mshell.desktop.focus.next +go_keys["["] = mshell.desktop.focus.prev +go_keys[","] = mshell.monitor.focus.prev +go_keys["."] = mshell.monitor.focus.next +send_keys[","] = mshell.window.move.to_monitor.prev +send_keys["."] = mshell.window.move.to_monitor.next + +mshell.keys.submap("go", go_keys) +mshell.keys.submap("send", send_keys) + +mshell.keys.submap("window", { + q = mshell.window.close, + k = mshell.window.kill, + f = mshell.window.float.toggle, + c = { function() mshell.window.center() end, desc = "center" }, + s = mshell.window.sticky.toggle, + t = mshell.window.on_top.toggle, + z = mshell.layout.master.zoom, + p = mshell.layout.master.promote, + w = mshell.window.fullscreen.window, + i = mshell.window.fullscreen.content, + a = mshell.window.fullscreen.both, + m = mshell.window.scratchpad.mark, + u = mshell.window.urgent.jump, +}) + +mshell.keys.submap("layout", { + t = mshell.layout.tiling, + m = mshell.layout.monocle, + g = mshell.layout.grid, + s = mshell.layout.spiral, + c = mshell.layout.centered, + b = mshell.layout.bstack, + o = mshell.layout.columns, + Space = mshell.layout.cycle, + ["="] = mshell.layout.master.count.inc, + ["-"] = mshell.layout.master.count.dec, +}) + +mshell.keys.submap("resize", { + h = mshell.layout.master.ratio.shrink, + l = mshell.layout.master.ratio.grow, + j = mshell.layout.cfact.shrink, + k = mshell.layout.cfact.grow, + r = mshell.layout.cfact.reset, + Left = mshell.window.resize.left, + Down = mshell.window.resize.down, + Up = mshell.window.resize.up, + Right = mshell.window.resize.right, +}, { persist = true }) + +mshell.keys.submap("apps", { + t = { terminal, desc = "terminal" }, + r = { launcher, desc = "launcher" }, + s = { capture("--select"), desc = "screenshot: region" }, + w = { capture("--window"), desc = "screenshot: window" }, + f = { capture("--screen"), desc = "screenshot: screen" }, + n = { notifications("--history"), desc = "notifications" }, + y = { notifications("--tray"), desc = "tray icons" }, +}) + +mshell.keys.submap("media", { + k = mshell.media.volume.up, + j = mshell.media.volume.down, + m = mshell.media.volume.mute, + p = mshell.media.play, + l = mshell.media.next, + h = mshell.media.prev, }, { persist = true }) -mshell.keys.leader("normal") -- a bare Win tap enters it; Esc or Win leaves +mshell.keys.submap("session", { + l = mshell.system.lock, + s = mshell.system.sleep, + h = mshell.system.hibernate, + o = mshell.system.logoff, + r = mshell.system.reboot, + p = mshell.system.shutdown, +}) + +mshell.keys.submap("mshell", { + r = mshell.config.reload, + u = mshell.config.update, + s = mshell.config.restart, + h = mshell.config.restart_helper, + b = mshell.bar.toggle, + t = mshell.bar.top, + f = mshell.bar.floating, + p = mshell.config.panic, + q = mshell.config.quit, +}) + +mshell.keys.submap("leader", { + g = "go", + m = "send", + w = "window", + l = "layout", + r = "resize", + a = "apps", + v = "media", + s = "session", + x = "mshell", + Space = { launcher, desc = "launcher" }, + Return = { terminal, desc = "terminal" }, + Tab = mshell.desktop.focus.last, +}) + +mshell.keys.leader("leader") ----------------------------------------------------------------------- --- Startup ----------------------------------------------------------------------- --- Nothing is launched by default — you may not have any of the usual --- suspects installed. Add what you want: --- --- mshell.exec.startup("alacritty.exe") --- mshell.exec.startup("wt.exe", "-p Ubuntu") -- arguments are a second string +if MNOTIFY then mshell.exec.startup(MNOTIFY) end +if MRUN then mshell.exec.startup(MRUN, "--daemon") end diff --git a/harden.reg b/harden.reg index 66bf2ca..183b115 100644 --- a/harden.reg +++ b/harden.reg @@ -17,7 +17,7 @@ Windows Registry Editor Version 5.00 ; Ctrl+Alt+Del is a kernel Secure Attention Sequence and cannot be disabled by ; anything here or anywhere in user space. That is by design. ; -; Apply: double-click, or reg import harden.reg (install.bat does this) +; Apply: double-click, or reg import harden.reg (the installer's Windows tweaks option does this) ; Undo: run harden-undo.reg ; Most of these take effect on next sign-in. ; ============================================================================ diff --git a/install.bat b/install.bat deleted file mode 100644 index 85d4bbb..0000000 --- a/install.bat +++ /dev/null @@ -1,271 +0,0 @@ -@echo off -setlocal -set DEST=C:\mshell -set CFGDIR=%APPDATA%\mshell - -set "WANTHELPERTASK=" -set "NOHELPERTASK=" -set "MACHINEWIDE=" -set "NORESTART=" -for %%a in (%*) do ( - if /I "%%~a"=="/helper" set "WANTHELPERTASK=1" - if /I "%%~a"=="--helper" set "WANTHELPERTASK=1" - if /I "%%~a"=="/nohelper" set "NOHELPERTASK=1" - if /I "%%~a"=="--nohelper" set "NOHELPERTASK=1" - if /I "%%~a"=="/machine" set "MACHINEWIDE=1" - if /I "%%~a"=="--machine" set "MACHINEWIDE=1" - if /I "%%~a"=="/norestart" set "NORESTART=1" - if /I "%%~a"=="--norestart" set "NORESTART=1" -) - -set "HIVE=HKCU" -set "SCOPE=per-user" -if defined MACHINEWIDE ( - set "HIVE=HKLM" - set "SCOPE=MACHINE-WIDE" - net session >nul 2>&1 || ( - echo. - echo ERROR: /machine needs an elevated prompt. - echo Right-click cmd.exe and pick "Run as administrator". - echo. - exit /b 1 - ) - echo. - echo *** MACHINE-WIDE INSTALL *** - echo Every account on this computer will get mshell as its shell. - echo Each user still needs their own %%APPDATA%%\mshell\init.lua; - echo without one they land on the six-binding fallback keymap. - echo. - choice /C YN /M " Continue" || exit /b 1 -) - -set "WASRUNNING=" -set "RESTARTED=" -set "OLDPID=" -call :running && set "WASRUNNING=1" -for /f "tokens=2 delims=," %%p in ('tasklist /FI "IMAGENAME eq mshell.exe" /NH /FO CSV 2^>nul') do set "OLDPID=%%~p" - -echo. -echo Installing mshell to %DEST% ... -if not exist "%DEST%" mkdir "%DEST%" - -copy /Y "%~dp0mshell.exe" "%DEST%\mshell.exe" >nul 2>&1 -if errorlevel 1 ( - echo mshell.exe is in use ^(running as your shell^) - staging the update ... - del "%DEST%\mshell.exe.old" >nul 2>&1 - move /Y "%DEST%\mshell.exe" "%DEST%\mshell.exe.old" >nul 2>&1 - copy /Y "%~dp0mshell.exe" "%DEST%\mshell.exe" >nul || goto :fail -) - -copy /Y "%~dp0mshelld.exe" "%DEST%\mshelld.exe" >nul 2>&1 -if errorlevel 1 ( - echo mshelld.exe is in use ^(the helper is running^) - staging the update ... - del "%DEST%\mshelld.exe.old" >nul 2>&1 - move /Y "%DEST%\mshelld.exe" "%DEST%\mshelld.exe.old" >nul 2>&1 - copy /Y "%~dp0mshelld.exe" "%DEST%\mshelld.exe" >nul || goto :fail -) - -if not exist "%CFGDIR%" mkdir "%CFGDIR%" -if exist "%CFGDIR%\init.lua" ( - echo Keeping your existing config at %CFGDIR%\init.lua - copy /Y "%~dp0config\init.lua" "%CFGDIR%\init.lua.new" >nul 2>&1 - echo This release's default config is beside it as init.lua.new - echo Upgrading from 0.5.0? Compare them - see CHANGELOG.md, "Upgrading" -) else if exist "%DEST%\config\init.lua" ( - move /Y "%DEST%\config\init.lua" "%CFGDIR%\init.lua" >nul || goto :fail - rmdir "%DEST%\config" >nul 2>&1 - echo Moved your old %DEST%\config\init.lua to %CFGDIR%\init.lua -) else ( - copy /Y "%~dp0config\init.lua" "%CFGDIR%\init.lua" >nul || goto :fail - echo Default config installed to %CFGDIR%\init.lua -) - -if not exist "%CFGDIR%\meta" mkdir "%CFGDIR%\meta" -copy /Y "%~dp0meta\mshell.lua" "%CFGDIR%\meta\mshell.lua" >nul 2>&1 -copy /Y "%~dp0meta\types.lua" "%CFGDIR%\meta\types.lua" >nul 2>&1 -if not exist "%CFGDIR%\.luarc.json" ( - copy /Y "%~dp0config\.luarc.json" "%CFGDIR%\.luarc.json" >nul 2>&1 - echo Editor type definitions installed to %CFGDIR%\meta -) - -echo Pointing the %SCOPE% shell at %DEST%\mshell.exe ... -reg add "%HIVE%\Software\Microsoft\Windows NT\CurrentVersion\Winlogon" ^ - /v Shell /t REG_SZ /d "%DEST%\mshell.exe --shell" /f >nul || goto :fail - -echo Applying registry hardening (the `input` tweaks) ... -"%DEST%\mshell.exe" --tweaks apply input - -"%DEST%\mshell.exe" --tweaks apply apps - -net session >nul 2>&1 || ( - echo ^(Not an administrator prompt, so the three policy values were left - echo alone: the Win-key hotkey policy, Win+L, and the Chromium - echo occlusion policy that keeps a hidden browser window drawing. - echo Re-run this from an admin prompt to apply those.^) -) -copy /Y "%~dp0harden.reg" "%DEST%\harden.reg" >nul -copy /Y "%~dp0harden-undo.reg" "%DEST%\harden-undo.reg" >nul - -call :helpertask - -if not defined WASRUNNING goto :report - -if defined NORESTART ( - echo Installed. The caller asked to restart mshell itself - not touching it. - goto :report -) - -set "AUTORESTART=0x1" -for /f "tokens=3" %%v in ('reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoRestartShell 2^>nul ^| find /I "AutoRestartShell"') do set "AUTORESTART=%%v" -if "%AUTORESTART%"=="0x0" goto :noautorestart -if "%AUTORESTART%"=="0" goto :noautorestart - -echo Restarting mshell so the new build takes over ... -taskkill /F /IM mshell.exe >nul 2>&1 - -call :wait 3 -call :running || goto :start_it -call :oldalive && goto :nokill -goto :restarted - -:start_it -start "" "%DEST%\mshell.exe" --shell -call :wait 2 -call :running || goto :nostart - -:restarted -del "%DEST%\mshell.exe.old" >nul 2>&1 -set RESTARTED=1 -echo Restarted - the build you just installed is the one running. -goto :report - -:nokill -echo Could not stop the running mshell ^(not enough rights?^). -echo The new build IS installed - sign out and back in to switch to it. -goto :report - -:noautorestart -echo Not restarting: AutoRestartShell is off on this machine, so stopping the -echo shell would log you out rather than bring mshell back. -echo The new build IS installed - sign out and back in to switch to it. -goto :report - -:nostart -echo mshell did not come back up. Press Ctrl+Shift+Esc ^> File ^> Run new -echo task, run %DEST%\mshell.exe --shell , or just sign out and back in. -goto :report - -:report -echo. -if defined RESTARTED ( - echo Done. mshell is the shell for THIS user account and the new build - echo is already running - no sign-out needed. -) else ( - echo Done. mshell is now the shell for THIS user account. - echo Sign out and back in to start it ^(Explorer will NOT launch^). -) -echo ^(Ctrl+Alt+Del is a kernel sequence and cannot be disabled - by design.^) -echo. -echo Your config lives at: -echo %CFGDIR%\init.lua -echo Edit it and press Win+Shift+R to reload without signing out. -echo. -if defined HELPERHINT ( - echo Windows owned by elevated processes ^(Task Manager, regedit^) will - echo float rather than tile: that needs the privileged helper, which is - echo installed at %DEST%\mshelld.exe but not started. To run it at every - echo sign-in, re-run this script from an administrator prompt - it - echo registers the helper whenever it can. See INSTALL.md, - echo "The privileged helper"; /nohelper skips it. - echo. -) -echo RECOVERY if something goes wrong: -echo Press Ctrl+Shift+Esc (Task Manager works with no shell), -echo File ^> Run new task ^> type explorer.exe for a temporary -echo desktop, or regedit to remove the Shell value, or just -echo run uninstall.bat. -echo. -goto :eof - -:fail -echo. -echo INSTALL FAILED. Make sure mshell.exe, mshelld.exe and -echo config\init.lua are next to this script, and that you can -echo write to C:\ and %CFGDIR%. -exit /b 1 - -:helpertask -set "HELPERTASK=" -schtasks /query /tn "mshelld" >nul 2>&1 && set "HELPERTASK=1" - -if defined HELPERTASK goto :helper_refresh -if defined NOHELPERTASK goto :helper_skip -call :iselevated && goto :helper_create -if defined WANTHELPERTASK goto :helper_needadmin -set "HELPERHINT=1" -goto :eof - -:helper_skip -echo /nohelper: the mshelld logon task was not registered. -goto :eof - -:helper_create -call :iselevated || goto :helper_needadmin -echo Registering the mshelld logon task ... -schtasks /create /tn "mshelld" /tr "%DEST%\mshelld.exe" /sc onlogon /rl highest /f >nul 2>&1 || goto :helper_createfail - -:helper_refresh -call :helperrunning || goto :helper_start -schtasks /end /tn "mshelld" >nul 2>&1 -call :wait 2 -call :helperrunning && goto :helper_nostop - -:helper_start -schtasks /run /tn "mshelld" >nul 2>&1 -call :wait 2 -call :helperrunning || goto :helper_nostart -del "%DEST%\mshelld.exe.old" >nul 2>&1 -echo Privileged helper running - windows owned by elevated processes will tile. -goto :eof - -:helper_nostop -echo Could not stop the running mshelld ^(needs administrator^). -echo The new helper IS installed - it takes over at your next sign-in. -goto :eof - -:helper_nostart -echo The mshelld logon task is registered but the helper is not up yet. -echo It starts at your next sign-in; %%TEMP%%\mshelld.log says why if it does not. -goto :eof - -:helper_needadmin -set "HELPERHINT=1" -echo /helper needs an administrator prompt ^(the task is /rl highest^). -echo mshelld.exe IS installed - re-run install.bat as administrator. -goto :eof - -:helper_createfail -echo Could not register the mshelld logon task. By hand: -echo schtasks /create /tn "mshelld" /tr "%DEST%\mshelld.exe" /sc onlogon /rl highest /f -goto :eof - -:running -tasklist /FI "IMAGENAME eq mshell.exe" /NH 2>nul | find /I "mshell.exe" >nul -goto :eof - -:oldalive -if not defined OLDPID exit /b 1 -tasklist /FI "PID eq %OLDPID%" /NH 2>nul | find /I "mshell.exe" >nul -goto :eof - -:helperrunning -tasklist /FI "IMAGENAME eq mshelld.exe" /NH 2>nul | find /I "mshelld.exe" >nul -goto :eof - -:iselevated -net session >nul 2>&1 -goto :eof - -:wait -timeout /t %1 /nobreak >nul 2>&1 || ping -n %1 127.0.0.1 >nul 2>&1 -goto :eof diff --git a/meta/mshell.lua b/meta/mshell.lua index a264167..599a5bc 100644 --- a/meta/mshell.lua +++ b/meta/mshell.lua @@ -697,6 +697,9 @@ function mshell.config.panic() end ---Install the latest release. function mshell.config.update() end +---Restart mshell into the build that is installed now. +function mshell.config.restart() end + ---Restart the privileged helper. function mshell.config.restart_helper() end diff --git a/packaging/build-msi.ps1 b/packaging/build-msi.ps1 new file mode 100644 index 0000000..bfd2d90 --- /dev/null +++ b/packaging/build-msi.ps1 @@ -0,0 +1,138 @@ +param( + [Parameter(Mandatory)] [string]$Version, + [Parameter(Mandatory)] [string]$Stage, + [Parameter(Mandatory)] [string]$Out, + [string]$Companions, + [switch]$LatestCompanions, + [string]$WixVersion = '5.0.2', + [string]$TimestampUrl = 'http://timestamp.digicert.com' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$Stage = (Resolve-Path $Stage).Path +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $Companions) { $Companions = Join-Path $here 'companions.json' } +$pins = Get-Content $Companions -Raw | ConvertFrom-Json + +function Get-Companion { + param([string]$Name) + + $pin = $pins.$Name + if (-not $LatestCompanions -and -not $pin) { throw "$Companions has no pin for $Name" } + $endpoint = if ($LatestCompanions) { 'latest' } else { "tags/v$($pin.version.TrimStart('v'))" } + $headers = @{ 'User-Agent' = 'mshell-build' } + if ($env:GH_TOKEN) { $headers.Authorization = "Bearer $env:GH_TOKEN" } + + $release = Invoke-RestMethod -Headers $headers "https://api.github.com/repos/blendonl/$Name/releases/$endpoint" + $asset = $release.assets | Where-Object name -like "$Name-*-win64.zip" | Select-Object -First 1 + if (-not $asset) { throw "$Name $($release.tag_name) has no $Name-*-win64.zip" } + + $work = Join-Path ([IO.Path]::GetTempPath()) "mshell-msi-$Name-$([guid]::NewGuid())" + New-Item -ItemType Directory $work | Out-Null + try { + $zip = Join-Path $work $asset.name + Invoke-WebRequest -Headers $headers $asset.browser_download_url -OutFile $zip -UseBasicParsing + + $got = (Get-FileHash $zip -Algorithm SHA256).Hash + if (-not $LatestCompanions) { + if ($got -ne $pin.sha256) { throw "$($asset.name): sha256 $got does not match the pinned $($pin.sha256)" } + } elseif ($asset.digest -like 'sha256:*') { + $want = $asset.digest.Substring(7) + if ($got -ne $want) { throw "$($asset.name): sha256 $got does not match the published $want" } + } + + Expand-Archive $zip (Join-Path $work 'unpacked') + $payload = Get-ChildItem (Join-Path $work 'unpacked') -Directory | Select-Object -First 1 + if (-not (Test-Path (Join-Path $payload.FullName "$Name.exe"))) { + throw "$($asset.name) has no $Name.exe at its root" + } + + $dest = Join-Path $Stage $Name + Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory $dest | Out-Null + Copy-Item (Join-Path $payload.FullName '*') $dest -Recurse -Force + } finally { + Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "bundled $Name $($release.tag_name)" + "$Name $($release.tag_name.TrimStart('v'))" +} + +function ConvertTo-Rtf { + param([string]$Text) + $escaped = $Text.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}') + $body = ($escaped -split "`r?`n") -join "\par`r`n" + "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\fs18`r`n$body\par`r`n}" +} + +function Find-SignTool { + $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $tool = Get-ChildItem $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'x64' } | + Sort-Object { $_.Directory.Parent.Name } -Descending | + Select-Object -First 1 + if (-not $tool) { throw 'signtool.exe was not found in the Windows SDK' } + $tool.FullName +} + +function Get-SigningCert { + if (-not $env:MSHELL_SIGN_PFX_BASE64) { + Write-Warning 'MSHELL_SIGN_PFX_BASE64 is not set, so the binaries and the installer stay unsigned' + return $null + } + $pfx = Join-Path ([IO.Path]::GetTempPath()) "mshell-sign-$([guid]::NewGuid()).pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:MSHELL_SIGN_PFX_BASE64)) + @{ Path = $pfx; Password = $env:MSHELL_SIGN_PFX_PASSWORD; Tool = Find-SignTool } +} + +function Invoke-Sign { + param($Cert, [string[]]$Files, [string]$Description) + if (-not $Cert -or -not $Files) { return } + $passArgs = if ($Cert.Password) { @('/p', $Cert.Password) } else { @() } + & $Cert.Tool sign /f $Cert.Path @passArgs /fd sha256 /td sha256 /tr $TimestampUrl /d $Description @Files + if ($LASTEXITCODE) { throw "signtool failed ($LASTEXITCODE)" } +} + +$bundled = @( + Get-Companion 'mrun' + Get-Companion 'mnotify' + Get-Companion 'mcapture' +) +$bundled | Set-Content (Join-Path $Stage 'docs\BUNDLED.txt') + +ConvertTo-Rtf (Get-Content (Join-Path $Stage 'docs\LICENSE') -Raw) | + Set-Content (Join-Path $Stage 'license.rtf') -Encoding ascii + +if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { + dotnet tool install --global wix --version $WixVersion + $env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools" +} +wix extension add -g "WixToolset.UI.wixext/$WixVersion" "WixToolset.Util.wixext/$WixVersion" +if ($LASTEXITCODE) { throw "wix extension add failed ($LASTEXITCODE)" } + +$cert = Get-SigningCert +try { + $unsigned = Get-ChildItem $Stage -Recurse -Include *.exe, *.dll | + Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -ne 'Valid' } | + ForEach-Object FullName + Invoke-Sign $cert $unsigned 'mshell' + + New-Item -ItemType Directory -Force (Split-Path -Parent $Out) | Out-Null + wix build (Join-Path $here 'mshell.wxs') ` + -arch x64 ` + -d "Version=$Version" ` + -d "Stage=$Stage" ` + -ext WixToolset.UI.wixext ` + -ext WixToolset.Util.wixext ` + -o $Out + if ($LASTEXITCODE) { throw "wix build failed ($LASTEXITCODE)" } + + Invoke-Sign $cert @((Resolve-Path $Out).Path) "mshell $Version" +} finally { + if ($cert) { Remove-Item $cert.Path -Force -ErrorAction SilentlyContinue } +} + +Write-Host "built $Out" diff --git a/packaging/companions.json b/packaging/companions.json new file mode 100644 index 0000000..42cffc8 --- /dev/null +++ b/packaging/companions.json @@ -0,0 +1,14 @@ +{ + "mrun": { + "version": "0.6.1", + "sha256": "20d04244eb832f0e3f815ab4ebd84a595b61dce646fc5f76e86c454a1238cbc7" + }, + "mnotify": { + "version": "0.4.0", + "sha256": "795c920719b7d3db8e2d5f134f1a62c8b5ca95e7099ca75c7a12c40ca3839da9" + }, + "mcapture": { + "version": "0.1.0", + "sha256": "f8fda12ba7ca0b8eaf6208ad9135537afeb742764524ca7e05a825a73f4d1a76" + } +} diff --git a/packaging/mshell.wxs b/packaging/mshell.wxs index f046b6c..545b253 100644 --- a/packaging/mshell.wxs +++ b/packaging/mshell.wxs @@ -1,67 +1,212 @@ + - - + Version="$(var.Version)" + UpgradeCode="6f1c9a52-3d84-4a71-9d0e-2f0a6b5c8e13" + Scope="perMachine" + Compressed="yes"> - + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + - + + - - - + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/action_table.h b/src/action_table.h index 66dbf2e..3489d36 100644 --- a/src/action_table.h +++ b/src/action_table.h @@ -108,5 +108,6 @@ X(ACTION_RELOAD, act_reload) \ X(ACTION_QUIT, act_quit) \ X(ACTION_UPDATE, act_update) \ + X(ACTION_RESTART, act_restart) \ X(ACTION_RESTART_HELPER, act_restart_helper) \ X(ACTION_PANIC, act_panic) diff --git a/src/actions.c b/src/actions.c index dde1b46..fc7c8ce 100644 --- a/src/actions.c +++ b/src/actions.c @@ -401,6 +401,8 @@ static void act_quit(const ActionCtx *c) { static void act_update(const ActionCtx *c) { update_install_async(); } +static void act_restart(const ActionCtx *c) { update_restart_installed(); } + static void act_restart_helper(const ActionCtx *c) { helper_restart_async(); } static void act_panic(const ActionCtx *c) { diff --git a/src/api_spec.c b/src/api_spec.c index 9f30e56..1881620 100644 --- a/src/api_spec.c +++ b/src/api_spec.c @@ -727,6 +727,10 @@ const ApiEntry api_spec[] = { API_ACTION, 0, "Install the latest release.", "", NULL }, + { "config.restart", "restart", ACTION_RESTART, + API_ACTION, 0, + "Restart mshell into the build that is installed now.", + "", NULL }, { "config.restart_helper", "restart_helper", ACTION_RESTART_HELPER, API_ACTION, 0, "Restart the privileged helper.", diff --git a/src/api_spec.h b/src/api_spec.h index 53a8a6b..fa9f2e7 100644 --- a/src/api_spec.h +++ b/src/api_spec.h @@ -119,6 +119,7 @@ typedef enum { ACTION_QUIT, ACTION_UPDATE, + ACTION_RESTART, ACTION_PANIC, diff --git a/src/cli.c b/src/cli.c index c33c38d..3ebec22 100644 --- a/src/cli.c +++ b/src/cli.c @@ -37,12 +37,28 @@ static int run_displays(const char *cmdline) { return 0; } +static int for_each_group(const char *groups, int (*fn)(const wchar_t *)) { + wchar_t group[64]; + int done = 0, n = 0; + bool any = false; + + while (groups && sscanf(groups, " %63ls%n", group, &n) == 1) { + done += fn(group); + groups += n; + any = true; + } + return any ? done : fn(NULL); +} + static int run_tweaks(const char *cmdline) { const char *arg = cli_flag_value(cmdline, "--tweaks"); wchar_t group[64] = {0}; char verb[32] = {0}; + int used = 0; - if (arg) sscanf(arg, "%31s %63ls", verb, group); + if (arg) sscanf(arg, "%31s%n", verb, &used); + const char *groups = arg ? arg + used : NULL; + if (groups) sscanf(groups, " %63ls", group); if (!verb[0] || !strcmp(verb, "list")) { tweaks_list(); @@ -51,14 +67,14 @@ static int run_tweaks(const char *cmdline) { if (!strcmp(verb, "apply")) { char msg[128]; snprintf(msg, sizeof msg, "applied %d tweaks", - tweaks_apply(group[0] ? group : NULL)); + for_each_group(groups, tweaks_apply)); console_print(msg); return 0; } if (!strcmp(verb, "revert")) { char msg[128]; snprintf(msg, sizeof msg, "reverted %d tweaks", - tweaks_revert(group[0] ? group : NULL)); + for_each_group(groups, tweaks_revert)); console_print(msg); return 0; } @@ -67,7 +83,7 @@ static int run_tweaks(const char *cmdline) { return 0; } console_print("usage: mshell --tweaks " - "[input|visual|quiet|apps|all]"); + "[input|visual|quiet|apps|all]..."); return 1; } diff --git a/src/helper.c b/src/helper.c index b70d0b9..06eb805 100644 --- a/src/helper.c +++ b/src/helper.c @@ -134,8 +134,8 @@ static void helper_open(void) { if (!helper_connect()) log_w(L"helper: mshelld.exe is not running. Windows owned by elevated " L"processes will float instead of tiling and will stay on every " - L"desktop. Re-run install.bat from an administrator prompt to " - L"change that. Cloaking is NOT among the things the " + L"desktop. Run the mshell installer again and turn on the " + L"privileged helper to change that. Cloaking is NOT among the things the " L"helper fixes: DWMWA_CLOAK is owner-only for every process, and " L"the shell cloak needs an immersive shell that only explorer.exe " L"provides, so hiding sinks the window under the backdrop " @@ -323,8 +323,8 @@ static DWORD WINAPI helper_restart_thread(LPVOID unused) { if (!helper_schtasks(L"end", &rc)) helper_notify(NOTIFY_ERROR, L"could not run schtasks to stop the " L"helper — is the mshelld task registered? " - L"install.bat creates it when run as " - L"administrator."); + L"the installer's privileged-helper " + L"option creates it."); Sleep(HELPER_SETTLE_MS); rc = 1; diff --git a/src/main.c b/src/main.c index 13aae84..652f829 100644 --- a/src/main.c +++ b/src/main.c @@ -98,19 +98,188 @@ static bool file_exists(const wchar_t *path) { !(attr & FILE_ATTRIBUTE_DIRECTORY); } -static bool portable_config_path(wchar_t *out, size_t out_len) { - wchar_t exe_dir[MAX_PATH]; - DWORD n = GetModuleFileNameW(NULL, exe_dir, MAX_PATH); - if (n == 0 || n >= MAX_PATH) return false; +static bool exe_dir(wchar_t *out, size_t out_len) { + DWORD n = GetModuleFileNameW(NULL, out, (DWORD)out_len); + if (n == 0 || n >= out_len) return false; - wchar_t *last_slash = wcsrchr(exe_dir, L'\\'); + wchar_t *last_slash = wcsrchr(out, L'\\'); if (!last_slash) return false; *last_slash = L'\0'; + return true; +} + +static bool portable_config_path(wchar_t *out, size_t out_len) { + wchar_t dir[MAX_PATH]; + if (!exe_dir(dir, MAX_PATH)) return false; - int w = _snwprintf(out, out_len, L"%ls\\config\\init.lua", exe_dir); + int w = _snwprintf(out, out_len, L"%ls\\config\\init.lua", dir); return w > 0 && (size_t)w < out_len; } +static void copy_shipped(const wchar_t *from_dir, const wchar_t *to_dir, + const wchar_t *name, bool overwrite) { + wchar_t src[MAX_PATH], dst[MAX_PATH]; + int a = _snwprintf(src, MAX_PATH, L"%ls\\%ls", from_dir, name); + int b = _snwprintf(dst, MAX_PATH, L"%ls\\%ls", to_dir, name); + if (a <= 0 || a >= MAX_PATH || b <= 0 || b >= MAX_PATH) return; + if (!file_exists(src)) return; + + if (CopyFileW(src, dst, !overwrite) && !overwrite) + log_msg(LOG_INFO, L"config: installed the default %ls", dst); +} + +#define DEFAULTS_KEY L"Software\\mshell" +#define DEFAULTS_VALUE L"DefaultConfigHash" +#define SEED_MAX_BYTES (4u * 1024u * 1024u) + +typedef enum { + SEED_NOTICE_NONE = 0, + SEED_NOTICE_WELCOME, + SEED_NOTICE_NEW_DEFAULT, +} SeedNotice; + +static SeedNotice s_seed_notice; +static ULONGLONG s_seed_hash; +static wchar_t s_seed_new_path[MAX_PATH]; + +static char *read_small_file(const wchar_t *path, DWORD *len) { + HANDLE f = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (f == INVALID_HANDLE_VALUE) return NULL; + + char *data = NULL; + LARGE_INTEGER size; + if (GetFileSizeEx(f, &size) && size.QuadPart <= SEED_MAX_BYTES) { + DWORD n = (DWORD)size.QuadPart, got = 0; + data = malloc(n + 1); + if (data && ReadFile(f, data, n, &got, NULL) && got == n) { + *len = n; + } else { + free(data); + data = NULL; + } + } + CloseHandle(f); + return data; +} + +static ULONGLONG fnv1a64(const char *p, DWORD n) { + ULONGLONG h = 0xcbf29ce484222325ULL; + for (DWORD i = 0; i < n; i++) { + h ^= (unsigned char)p[i]; + h *= 0x100000001b3ULL; + } + return h ? h : 1; +} + +static ULONGLONG defaults_stamp_read(void) { + ULONGLONG v = 0; + DWORD sz = sizeof(v); + if (RegGetValueW(HKEY_CURRENT_USER, DEFAULTS_KEY, DEFAULTS_VALUE, + RRF_RT_REG_QWORD, NULL, &v, &sz) != ERROR_SUCCESS) + return 0; + return v; +} + +static void defaults_stamp_write(ULONGLONG v) { + HKEY k; + if (RegCreateKeyExW(HKEY_CURRENT_USER, DEFAULTS_KEY, 0, NULL, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, NULL, &k, + NULL) != ERROR_SUCCESS) + return; + RegSetValueExW(k, DEFAULTS_VALUE, 0, REG_QWORD, (const BYTE *)&v, sizeof(v)); + RegCloseKey(k); +} + +static void seed_init_lua(const wchar_t *shipped_cfg, const wchar_t *user_dir) { + wchar_t shipped[MAX_PATH], user[MAX_PATH], fresh[MAX_PATH]; + int a = _snwprintf(shipped, MAX_PATH, L"%ls\\init.lua", shipped_cfg); + int b = _snwprintf(user, MAX_PATH, L"%ls\\init.lua", user_dir); + int c = _snwprintf(fresh, MAX_PATH, L"%ls\\init.lua.new", user_dir); + if (a <= 0 || a >= MAX_PATH || b <= 0 || b >= MAX_PATH || + c <= 0 || c >= MAX_PATH) + return; + + DWORD shipped_len = 0; + char *shipped_data = read_small_file(shipped, &shipped_len); + if (!shipped_data) return; + + ULONGLONG hash = fnv1a64(shipped_data, shipped_len); + ULONGLONG stamp = defaults_stamp_read(); + + if (!file_exists(user)) { + if (CopyFileW(shipped, user, TRUE)) { + log_msg(LOG_INFO, L"config: installed the default %ls", user); + s_seed_notice = SEED_NOTICE_WELCOME; + s_seed_hash = hash; + } + } else if (stamp != hash) { + DWORD user_len = 0; + char *user_data = read_small_file(user, &user_len); + bool same = user_data && user_len == shipped_len && + memcmp(user_data, shipped_data, user_len) == 0; + free(user_data); + + if (same) { + s_seed_notice = stamp ? SEED_NOTICE_NONE : SEED_NOTICE_WELCOME; + s_seed_hash = hash; + } else if (CopyFileW(shipped, fresh, FALSE)) { + log_msg(LOG_INFO, L"config: the default config changed — the new " + L"one is at %ls", fresh); + s_seed_notice = SEED_NOTICE_NEW_DEFAULT; + s_seed_hash = hash; + wcsncpy(s_seed_new_path, fresh, MAX_PATH - 1); + } + } + free(shipped_data); +} + +static void seed_user_config(const wchar_t *user_dir) { + wchar_t root[MAX_PATH], shipped_cfg[MAX_PATH], shipped_meta[MAX_PATH]; + wchar_t user_meta[MAX_PATH]; + if (!exe_dir(root, MAX_PATH)) return; + + int a = _snwprintf(shipped_cfg, MAX_PATH, L"%ls\\config", root); + int b = _snwprintf(shipped_meta, MAX_PATH, L"%ls\\meta", root); + int c = _snwprintf(user_meta, MAX_PATH, L"%ls\\meta", user_dir); + if (a <= 0 || a >= MAX_PATH || b <= 0 || b >= MAX_PATH || + c <= 0 || c >= MAX_PATH) + return; + + CreateDirectoryW(user_dir, NULL); + seed_init_lua(shipped_cfg, user_dir); + copy_shipped(shipped_cfg, user_dir, L".luarc.json", false); + + CreateDirectoryW(user_meta, NULL); + copy_shipped(shipped_meta, user_meta, L"mshell.lua", true); + copy_shipped(shipped_meta, user_meta, L"types.lua", true); +} + +static void show_seed_notice(void) { + if (!s_seed_hash) return; + defaults_stamp_write(s_seed_hash); + + switch (s_seed_notice) { + case SEED_NOTICE_WELCOME: + notify_show(L"Welcome to mshell. Tap Win for the menu, hold Win for " + L"shortcuts: Win+Space launcher, Win+Return terminal, " + L"Win+1..9 desktops.", NOTIFY_INFO, 15000); + break; + case SEED_NOTICE_NEW_DEFAULT: { + wchar_t text[MAX_PATH + 128]; + _snwprintf(text, sizeof text / sizeof text[0], + L"mshell has a new default config. Yours is unchanged; " + L"compare it with %ls", s_seed_new_path); + text[sizeof text / sizeof text[0] - 1] = L'\0'; + notify_show(text, NOTIFY_INFO, 15000); + break; + } + case SEED_NOTICE_NONE: + break; + } + s_seed_hash = 0; +} + #define CRASHLOOP_KEY L"Software\\mshell" #define CRASHLOOP_WINDOW 60ULL #define CRASHLOOP_LIMIT 3 @@ -231,6 +400,7 @@ void resolve_config_path(wchar_t *out, size_t out_len) { if (appdata_config_dir(dir, MAX_PATH)) { int n = _snwprintf(appdata, MAX_PATH, L"%ls\\init.lua", dir); have_appdata = n > 0 && (size_t)n < MAX_PATH; + if (have_appdata) seed_user_config(dir); } if (have_appdata && file_exists(appdata)) { if (path_copy(out, out_len, appdata)) return; @@ -497,6 +667,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, desktop_launch_app_if_empty(desktop_current_slot()); + show_seed_notice(); + log_w(L"mshell started — mode=%ls, on desktop '%ls', %d managed windows", g.test_mode ? L"test" : L"shell", desktop_current()->name, g.managed_count); diff --git a/src/mshell.h b/src/mshell.h index 9b1ca38..7da3da0 100644 --- a/src/mshell.h +++ b/src/mshell.h @@ -920,6 +920,7 @@ void update_check_async(void); void update_install_async(void); void update_clear_staged_image(void); +void update_restart_installed(void); void ipc_start(void); void ipc_stop(void); diff --git a/src/update.c b/src/update.c index 872af9f..5152f30 100644 --- a/src/update.c +++ b/src/update.c @@ -14,7 +14,7 @@ #define UPDATE_MAX_DOWNLOAD (64u * 1024u * 1024u) #define UPDATE_MAX_JSON (1u * 1024u * 1024u) -#define UPDATE_ASSET_SUFFIX "-win64.zip" +#define UPDATE_ASSET_SUFFIX ".msi" #define UPDATE_DIGEST_PREFIX "sha256:" @@ -250,7 +250,7 @@ void update_check_async(void) { static volatile LONG s_update_running = 0; static bool run_wait(const wchar_t *cmdline, const wchar_t *cwd, - DWORD timeout_ms, DWORD *exit_code) { + DWORD timeout_ms, WORD show, DWORD *exit_code) { wchar_t buf[2048]; if (!fmt_w(buf, ARRAYSIZE(buf), L"%ls", cmdline)) { log_err(L"update: command line does not fit in %d characters", @@ -264,10 +264,11 @@ static bool run_wait(const wchar_t *cmdline, const wchar_t *cwd, memset(&pi, 0, sizeof(pi)); si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; - si.wShowWindow = SW_HIDE; + si.wShowWindow = show; if (!CreateProcessW(NULL, buf, NULL, NULL, FALSE, - CREATE_NO_WINDOW, NULL, cwd, &si, &pi)) + show == SW_HIDE ? CREATE_NO_WINDOW : 0, + NULL, cwd, &si, &pi)) return false; bool ok = (WaitForSingleObject(pi.hProcess, timeout_ms) == WAIT_OBJECT_0); @@ -288,7 +289,7 @@ static bool prepare_workdir(wchar_t *out, size_t cap) { wchar_t cmd[MAX_PATH + 64]; if (!fmt_w(cmd, ARRAYSIZE(cmd), L"cmd.exe /c rd /s /q \"%ls\"", out)) return false; - run_wait(cmd, NULL, 15000, NULL); + run_wait(cmd, NULL, 15000, SW_HIDE, NULL); return CreateDirectoryW(out, NULL) || GetLastError() == ERROR_ALREADY_EXISTS; @@ -433,22 +434,27 @@ void update_clear_staged_image(void) { log_msg(LOG_INFO, L"update: removed the previous build (%ls)", old); } -static void update_restart_self(const wchar_t *version) { +static bool hand_over_to_successor(void) { if (!winlogon_restarts_the_shell()) { update_notify(NOTIFY_WARN, 30000, - L"mshell %ls is installed, but AutoRestartShell is off on " - L"this machine — stopping the shell would log you out. " - L"Sign out and back in to start the new build.", version); - return; + L"The new mshell is installed, but AutoRestartShell is " + L"off on this machine — stopping the shell would log you " + L"out. Sign out and back in to start it."); + return false; } if (!launch_successor()) { update_notify(NOTIFY_ERROR, 30000, - L"mshell %ls is installed, but the new build could not be " + L"The new mshell is installed, but it could not be " L"started, so this one keeps running. Sign out and back " - L"in to switch to it.", version); - return; + L"in to switch to it."); + return false; } + return true; +} + +static void update_restart_self(const wchar_t *version) { + if (!hand_over_to_successor()) return; update_notify(NOTIFY_INFO, 5000, L"mshell %ls installed — restarting.", version); @@ -458,10 +464,23 @@ static void update_restart_self(const wchar_t *version) { PostMessageW(g.message_window, WM_MSHELL_RESTART, 0, 0); } +void update_restart_installed(void) { + if (!running_as_installed_shell()) { + update_notify(NOTIFY_WARN, 12000, + L"This mshell is not the installed shell, so there is " + L"no installed build to restart into."); + return; + } + if (!hand_over_to_successor()) return; + + if (g.message_window) + PostMessageW(g.message_window, WM_MSHELL_RESTART, 0, 0); +} + static DWORD WINAPI install_thread(LPVOID param) { (void)param; - BYTE *body = NULL, *zip = NULL; + BYTE *body = NULL, *msi = NULL; update_notify(NOTIFY_INFO, 4000, L"Checking for updates …"); @@ -529,9 +548,9 @@ static DWORD WINAPI install_thread(LPVOID param) { update_notify(NOTIFY_INFO, 8000, L"Downloading mshell %ls …", latest); - DWORD zip_len = 0; - zip = http_get(url, &zip_len, UPDATE_MAX_DOWNLOAD, 120000); - if (!zip || zip_len == 0) { + DWORD msi_len = 0; + msi = http_get(url, &msi_len, UPDATE_MAX_DOWNLOAD, 120000); + if (!msi || msi_len == 0) { update_notify(NOTIFY_ERROR, 12000, L"Download of mshell %ls failed.", latest); goto out; @@ -540,7 +559,7 @@ static DWORD WINAPI install_thread(LPVOID param) { const char *want = digest_u8 + sizeof(UPDATE_DIGEST_PREFIX) - 1; char got[65]; - if (!sha256_hex(zip, zip_len, got)) { + if (!sha256_hex(msi, msi_len, got)) { update_notify(NOTIFY_ERROR, 12000, L"Could not hash the download to verify it."); goto out; @@ -553,6 +572,14 @@ static DWORD WINAPI install_thread(LPVOID param) { } log_msg(LOG_INFO, L"update: sha256 verified (%hs)", got); + if (!running_as_installed_shell()) { + update_notify(NOTIFY_WARN, 20000, + L"mshell %ls is available, but this session is not the " + L"installed shell — not installing. Run the .msi from " + L"the release page yourself.", latest); + goto out; + } + wchar_t dir[MAX_PATH]; if (!prepare_workdir(dir, ARRAYSIZE(dir))) { update_notify(NOTIFY_ERROR, 12000, @@ -560,83 +587,28 @@ static DWORD WINAPI install_thread(LPVOID param) { goto out; } - wchar_t zip_path[MAX_PATH]; - if (!fmt_w(zip_path, ARRAYSIZE(zip_path), L"%ls\\%ls", dir, asset)) { + wchar_t msi_path[MAX_PATH]; + if (!fmt_w(msi_path, ARRAYSIZE(msi_path), L"%ls\\%ls", dir, asset)) { update_notify(NOTIFY_ERROR, 12000, L"The path for %ls is too long to write.", asset); goto out; } - HANDLE f = CreateFileW(zip_path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + HANDLE f = CreateFileW(msi_path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (f == INVALID_HANDLE_VALUE) { - update_notify(NOTIFY_ERROR, 12000, L"Could not write %ls.", zip_path); + update_notify(NOTIFY_ERROR, 12000, L"Could not write %ls.", msi_path); goto out; } DWORD written = 0; - BOOL wrote = WriteFile(f, zip, zip_len, &written, NULL); + BOOL wrote = WriteFile(f, msi, msi_len, &written, NULL); CloseHandle(f); - if (!wrote || written != zip_len) { + if (!wrote || written != msi_len) { update_notify(NOTIFY_ERROR, 12000, L"Could not write the whole download to disk."); goto out; } - update_notify(NOTIFY_INFO, 6000, L"Unpacking mshell %ls …", latest); - - wchar_t cmd[MAX_PATH * 3]; - DWORD rc = 1; - if (!fmt_w(cmd, ARRAYSIZE(cmd), - L"tar.exe -xf \"%ls\" -C \"%ls\"", zip_path, dir)) { - update_notify(NOTIFY_ERROR, 12000, - L"The command to unpack %ls is too long to run.", asset); - goto out; - } - if (!run_wait(cmd, dir, 120000, &rc) || rc != 0) { - rc = 1; - if (!fmt_w(cmd, ARRAYSIZE(cmd), - L"powershell.exe -NoProfile -NonInteractive -Command " - L"\"Expand-Archive -LiteralPath '%ls' -DestinationPath " - L"'%ls' -Force\"", zip_path, dir) || - !run_wait(cmd, dir, 180000, &rc) || rc != 0) { - update_notify(NOTIFY_ERROR, 12000, - L"Could not unpack %ls.", asset); - goto out; - } - } - - wchar_t root[MAX_PATH], bat[MAX_PATH]; - if (!fmt_w(root, ARRAYSIZE(root), L"%ls\\%ls", dir, asset)) { - update_notify(NOTIFY_ERROR, 12000, - L"The path unpacked from %ls is too long to use.", asset); - goto out; - } - size_t rl = wcslen(root); - if (rl > 4 && _wcsicmp(root + rl - 4, L".zip") == 0) root[rl - 4] = L'\0'; - - if (!fmt_w(bat, ARRAYSIZE(bat), L"%ls\\install.bat", root)) { - update_notify(NOTIFY_ERROR, 12000, - L"The path to install.bat in %ls is too long to use.", - asset); - goto out; - } - - if (GetFileAttributesW(bat) == INVALID_FILE_ATTRIBUTES) { - update_notify(NOTIFY_ERROR, 15000, - L"Unpacked %ls but found no install.bat in it.", asset); - goto out; - } - - if (!running_as_installed_shell()) { - update_notify(NOTIFY_WARN, 20000, - L"mshell %ls is unpacked, but this session is not the " - L"installed shell — not installing. Run " - L"install.bat in %ls yourself.", latest, root); - goto out; - } - - update_notify(NOTIFY_INFO, 15000, L"Installing mshell %ls …", latest); - wchar_t ilog[MAX_PATH]; if (!install_log_path(ilog, ARRAYSIZE(ilog))) { update_notify(NOTIFY_ERROR, 12000, @@ -646,31 +618,40 @@ static DWORD WINAPI install_thread(LPVOID param) { wchar_t run[MAX_PATH * 3]; if (!fmt_w(run, ARRAYSIZE(run), - L"cmd.exe /c \"\"%ls\" /norestart >\"%ls\" 2>&1\"", bat, ilog)) { + L"msiexec.exe /i \"%ls\" /passive /norestart " + L"/l*v \"%ls\"", msi_path, ilog)) { update_notify(NOTIFY_ERROR, 12000, - L"The command to run install.bat is too long."); + L"The command to run the installer is too long."); goto out; } + update_notify(NOTIFY_INFO, 15000, + L"Installing mshell %ls — accept the Windows prompt.", latest); + DWORD irc = 1; - if (!run_wait(run, root, 300000, &irc)) { + if (!run_wait(run, dir, 900000, SW_SHOWNORMAL, &irc)) { update_notify(NOTIFY_ERROR, 25000, - L"install.bat did not finish. Nothing was restarted; " + L"The installer did not finish. Nothing was restarted; " L"%ls says how far it got.", ilog); goto out; } - if (irc != 0) { + if (irc == ERROR_INSTALL_USEREXIT || irc == ERROR_CANCELLED) { + update_notify(NOTIFY_WARN, 12000, + L"Update to mshell %ls cancelled.", latest); + goto out; + } + if (irc != ERROR_SUCCESS && irc != ERROR_SUCCESS_REBOOT_REQUIRED) { update_notify(NOTIFY_ERROR, 25000, - L"install.bat failed (exit %lu) — see %ls. The running " + L"The installer failed (exit %lu) — see %ls. The running " L"mshell is untouched.", irc, ilog); goto out; } - log_msg(LOG_INFO, L"update: install.bat finished cleanly (log: %ls)", ilog); + log_msg(LOG_INFO, L"update: msiexec finished cleanly (log: %ls)", ilog); update_restart_self(latest); out: - free(zip); + free(msi); free(body); InterlockedExchange(&s_update_running, 0); return 0; diff --git a/src/window_place.c b/src/window_place.c index 3d1882d..245d3d3 100644 --- a/src/window_place.c +++ b/src/window_place.c @@ -85,8 +85,8 @@ static void window_placement_refused(ManagedWindow *mw) { if (!mw->is_floating) { log_err(L"%p belongs to a higher-integrity process and cannot be placed " - L"— floating it. Re-run install.bat from an administrator " - L"prompt to tile it instead (see INSTALL.md).", (void *)mw->hwnd); + L"— floating it. Turn on the privileged helper in the mshell " + L"installer to tile it instead (see INSTALL.md).", (void *)mw->hwnd); window_set_floating(mw->hwnd, true); } } diff --git a/src/window_visibility.c b/src/window_visibility.c index 935a43f..3231ea4 100644 --- a/src/window_visibility.c +++ b/src/window_visibility.c @@ -251,8 +251,8 @@ void window_hide(ManagedWindow *mw) { log_err(L"hide: %p could not be taken off the screen by any " L"means — not sunk, not cloaked, not stashed, and " L"SW_HIDE was refused. It will be visible on every " - L"desktop. This is what mshelld.exe exists for: re-run " - L"install.bat from an administrator prompt " + L"desktop. This is what mshelld.exe exists for: turn on " + L"the privileged helper in the mshell installer " L"(see INSTALL.md).", (void *)mw->hwnd); } return; diff --git a/uninstall.bat b/uninstall.bat deleted file mode 100644 index 176eef2..0000000 --- a/uninstall.bat +++ /dev/null @@ -1,61 +0,0 @@ -@echo off -echo. -echo Restoring Explorer as the shell for this user ... -set "HIVE=HKCU" -set "MACHINEWIDE=" -for %%a in (%*) do ( - if /I "%%~a"=="/machine" set "MACHINEWIDE=1" - if /I "%%~a"=="--machine" set "MACHINEWIDE=1" -) -if defined MACHINEWIDE ( - set "HIVE=HKLM" - net session >nul 2>&1 || ( - echo ERROR: /machine needs an elevated prompt. - exit /b 1 - ) -) -reg delete "%HIVE%\Software\Microsoft\Windows NT\CurrentVersion\Winlogon" /v Shell /f >nul 2>&1 - -if %errorlevel%==0 ( - echo Shell override removed. -) else ( - echo No per-user Shell override was set ^(already on Explorer^). -) - -schtasks /query /tn "mshelld" >nul 2>&1 || goto :notask -echo Removing the mshelld logon task ... -schtasks /end /tn "mshelld" >nul 2>&1 -schtasks /delete /tn "mshelld" /f >nul 2>&1 -if %errorlevel%==0 ( - echo Helper logon task removed. -) else ( - echo Could not remove it ^(needs administrator^). From an admin prompt: - echo schtasks /delete /tn "mshelld" /f -) -:notask - -taskkill /F /IM mshelld.exe >nul 2>&1 - -echo Reverting OS-shortcut hardening ... -set "HAVEBACKUP=" -reg query "HKCU\Software\mshell\TweakBackup" >nul 2>&1 && set "HAVEBACKUP=1" -if defined HAVEBACKUP if exist "C:\mshell\mshell.exe" ( - "C:\mshell\mshell.exe" --tweaks revert input - "C:\mshell\mshell.exe" --tweaks revert apps - goto :reverted -) -if exist "%~dp0harden-undo.reg" ( - reg import "%~dp0harden-undo.reg" >nul -) else if exist "C:\mshell\harden-undo.reg" ( - reg import "C:\mshell\harden-undo.reg" >nul -) else ( - echo ^(harden-undo.reg not found next to this script or in C:\mshell — - echo Windows shortcut defaults were left unchanged.^) -) -:reverted - -echo. -echo Done. Sign out and back in to return to Explorer. -echo Your config is still at %APPDATA%\mshell\init.lua -echo ^(delete that folder too if you want mshell fully gone^). -echo.