Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# build output and test artifacts
tiny11.iso
tiny11-work/
tiny11_*.log

# python venv used during development
.venv/

.DS_Store
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This script generates a significantly reduced Windows 11 image. However, **it's
## ⚠️ Script versions:
- **tiny11maker.ps1** : The regular script, which removes a lot of bloat but keeps the system serviceable. You can add languages, updates, and features post-creation. This is the recommended script for regular use.
- ⚠️ **tiny11coremaker.ps1** : The core script, which removes even more bloat but also removes the ability to service the image. You cannot add languages, updates, or features post-creation. This is recommended for quick testing or development use.
- 🍎 **tiny11maker.sh** : Bash port of `tiny11maker.ps1` for macOS / Linux. Same removals and tweaks as the PowerShell version, with one documented limitation — see the [macOS / Linux instructions](#macos--linux-instructions) below.

## Instructions:
1. Download Windows 11 from the [Microsoft website](https://www.microsoft.com/software-download/windows11) or [Rufus](https://github.com/pbatard/rufus)
Expand All @@ -44,6 +45,50 @@ C:/path/to/your/tiny11/script.ps1 -ISO <letter> -SCRATCH <letter>

---

## macOS / Linux instructions:

`tiny11maker.sh` is a bash port of `tiny11maker.ps1`. It performs the same modifications using open-source userland tools — no Windows host required.

### Dependencies (macOS)

```bash
brew install wimlib hivex xorriso p7zip
```

On Linux, install the equivalents (`wimlib`, `libhivex-bin` / `hivex`, `xorriso`, `p7zip-full`) via your package manager.

### Usage

```bash
./tiny11maker.sh -s <source> [-o <output.iso>] [-i <index>] [-w <work_dir>] [-y]
```

Where `<source>` is either a Windows 11 ISO file *or* a directory containing the extracted ISO contents (i.e. with `sources/`, `boot/`, `efi/` at its top level).

Example — full path, prompted for SKU index, output next to the script:

```bash
./tiny11maker.sh -s ~/Downloads/Win11_24H2_English_x64.iso
```

Example — pre-pick index 6 (Pro), custom output path, non-interactive:

```bash
./tiny11maker.sh -s ~/Downloads/Win11.iso -i 6 -o ~/Desktop/tiny11.iso -y
```

The script writes a timestamped log next to itself (e.g. `tiny11_20251225_133742.log`) and uses ~30 GB of temporary disk space in the work directory.

### ⚠️ Differences vs. the PowerShell version

1. **No WinSxS component-store pruning.** `DISM /Cleanup-Image /StartComponentCleanup /ResetBase` is implemented inside Windows' servicing stack and has no cross-platform equivalent. The resulting ISO will be larger than what the PowerShell script produces on Windows (in testing against Windows 11 25H2 x64: 6.7 GB output vs. 7.9 GB source — about half the savings the Windows version delivers).
2. **`install.wim` may be split into `install.swm` files.** Homebrew's `xorriso` bottle is built without UDF support, so the ISO uses plain ISO 9660 Level 3 (per-file ceiling: 4 GiB). If the recaptured `install.wim` is larger than that, the script auto-splits it into `install.swm`/`install2.swm`. Windows' installer recognizes split wims natively — this is the same approach Microsoft itself uses for FAT32 boot USBs.
3. **Compression is non-solid LZMS.** The PowerShell script uses `/Compress:recovery` (solid LZMS). `wimlib-imagex split` rejects solid archives, so the bash port uses non-solid LZMS to stay splittable. Output is ~10-15% larger than solid would produce.
4. **Provisioned appx removal is folder-level.** The script deletes matched package directories under `Program Files\WindowsApps`, which prevents installation for new users. The staged-app registry entries aren't fully pruned the way `DISM /Remove-ProvisionedAppxPackage` would; Windows handles the missing paths gracefully on first boot.
5. **Offline registry edits use `libhivex` via Python ctypes.** Homebrew's `hivex` bottle omits `hivexregedit` (a Perl-bindings tool). The bash port ships a small `tiny11_hive.py` helper that calls `libhivex` directly to do surgical per-value edits — no Perl, no Python pip deps beyond the stdlib.

---

## What is removed:
<table>
<tbody>
Expand Down
261 changes: 261 additions & 0 deletions tiny11_hive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
tiny11_hive.py — apply registry edits to an offline Windows hive file.

Used by tiny11maker.sh as a stand-in for `hivexregedit --merge`, which is
not shipped in Homebrew's hivex bottle. This script calls libhivex directly
via ctypes so it can do surgical per-value edits (hivex_node_set_value)
rather than the destructive setval-replaces-all behavior of hivexsh.

Usage:
tiny11_hive.py <hive_path>
# Reads a JSON list of edit ops from stdin and applies them, then commits.

Op formats (key paths use backslashes, no leading backslash, no hive prefix):
{"action": "set_dword", "path": "Path\\To\\Key", "name": "ValueName", "value": 1}
{"action": "set_qword", "path": "...", "name": "...", "value": 12345}
{"action": "set_sz", "path": "...", "name": "...", "value": "string"}
{"action": "set_expand", "path": "...", "name": "...", "value": "%foo%"}
{"action": "set_multi", "path": "...", "name": "...", "value": ["a", "b"]}
{"action": "set_binary", "path": "...", "name": "...", "value_hex": "deadbeef"}
{"action": "del_key", "path": "Path\\To\\Key"}
"""

import ctypes
import ctypes.util
import errno
import json
import os
import subprocess
import sys
from ctypes import (
POINTER, Structure, c_char_p, c_int, c_size_t, c_ubyte, c_uint32, c_void_p,
)

# Windows registry value types
REG_NONE = 0
REG_SZ = 1
REG_EXPAND_SZ = 2
REG_BINARY = 3
REG_DWORD = 4
REG_MULTI_SZ = 7
REG_QWORD = 11

HIVEX_OPEN_VERBOSE = 1
HIVEX_OPEN_DEBUG = 2
HIVEX_OPEN_WRITE = 4
HIVEX_OPEN_UNSAFE = 8


def find_libhivex():
name = ctypes.util.find_library("hivex")
if name:
return name
candidates = [
"/opt/homebrew/lib/libhivex.dylib",
"/usr/local/lib/libhivex.dylib",
"/usr/lib/libhivex.so.0",
"/usr/lib/x86_64-linux-gnu/libhivex.so.0",
]
for c in candidates:
if os.path.exists(c):
return c
try:
prefix = subprocess.check_output(
["brew", "--prefix", "hivex"], text=True, stderr=subprocess.DEVNULL
).strip()
for ext in ("dylib", "so", "so.0"):
cand = os.path.join(prefix, "lib", f"libhivex.{ext}")
if os.path.exists(cand):
return cand
except Exception:
pass
return None


_lib_path = find_libhivex()
if not _lib_path:
sys.exit("ERR: libhivex not found. brew install hivex.")

lib = ctypes.CDLL(_lib_path, use_errno=True)


class HiveSetValue(Structure):
_fields_ = [
("key", c_char_p),
("t", c_uint32),
("len", c_size_t),
("value", POINTER(c_ubyte)),
]


# bind the libhivex functions we need
lib.hivex_open.argtypes = [c_char_p, c_int]
lib.hivex_open.restype = c_void_p

lib.hivex_close.argtypes = [c_void_p]
lib.hivex_close.restype = c_int

lib.hivex_commit.argtypes = [c_void_p, c_char_p, c_int]
lib.hivex_commit.restype = c_int

lib.hivex_root.argtypes = [c_void_p]
lib.hivex_root.restype = c_size_t

lib.hivex_node_get_child.argtypes = [c_void_p, c_size_t, c_char_p]
lib.hivex_node_get_child.restype = c_size_t

lib.hivex_node_add_child.argtypes = [c_void_p, c_size_t, c_char_p]
lib.hivex_node_add_child.restype = c_size_t

lib.hivex_node_delete_child.argtypes = [c_void_p, c_size_t]
lib.hivex_node_delete_child.restype = c_int

lib.hivex_node_set_value.argtypes = [c_void_p, c_size_t, POINTER(HiveSetValue), c_int]
lib.hivex_node_set_value.restype = c_int


def _check_errno(label):
e = ctypes.get_errno()
return f"{label}: errno={e} ({errno.errorcode.get(e, '?')}: {os.strerror(e)})"


def open_hive(path):
h = lib.hivex_open(path.encode("utf-8"), HIVEX_OPEN_WRITE)
if not h:
raise RuntimeError(_check_errno(f"hivex_open({path})"))
return h


def commit_hive(h):
rc = lib.hivex_commit(h, None, 0)
if rc != 0:
raise RuntimeError(_check_errno("hivex_commit"))


def close_hive(h):
lib.hivex_close(h)


def find_or_create_node(h, path):
"""Walk backslash-separated path from hive root, creating missing keys."""
node = lib.hivex_root(h)
if not node:
raise RuntimeError(_check_errno("hivex_root"))
if not path:
return node
for part in path.split("\\"):
if not part:
continue
ctypes.set_errno(0)
child = lib.hivex_node_get_child(h, node, part.encode("utf-8"))
if not child:
child = lib.hivex_node_add_child(h, node, part.encode("utf-8"))
if not child:
raise RuntimeError(_check_errno(f"add_child({part})"))
node = child
return node


def find_node(h, path):
"""Walk path; return 0 if any segment is missing."""
node = lib.hivex_root(h)
if not path:
return node
for part in path.split("\\"):
if not part:
continue
ctypes.set_errno(0)
node = lib.hivex_node_get_child(h, node, part.encode("utf-8"))
if not node:
return 0
return node


def set_value(h, node, name, vtype, data_bytes):
buf = (c_ubyte * len(data_bytes))(*data_bytes)
sv = HiveSetValue(
key=name.encode("utf-8"),
t=vtype,
len=len(data_bytes),
value=buf,
)
rc = lib.hivex_node_set_value(h, node, ctypes.byref(sv), 0)
if rc != 0:
raise RuntimeError(_check_errno(f"set_value({name})"))


def apply_op(h, op):
action = op["action"]
path = op.get("path", "")

if action == "del_key":
node = find_node(h, path)
if not node:
return f" skip del (not present): {path}"
rc = lib.hivex_node_delete_child(h, node)
if rc != 0:
raise RuntimeError(_check_errno(f"delete_child({path})"))
return f" del {path}"

# create-as-needed for set_* ops
node = find_or_create_node(h, path)
name = op["name"]

if action == "set_dword":
data = int(op["value"]).to_bytes(4, "little", signed=False)
set_value(h, node, name, REG_DWORD, data)
elif action == "set_qword":
data = int(op["value"]).to_bytes(8, "little", signed=False)
set_value(h, node, name, REG_QWORD, data)
elif action == "set_sz":
data = op["value"].encode("utf-16-le") + b"\x00\x00"
set_value(h, node, name, REG_SZ, data)
elif action == "set_expand":
data = op["value"].encode("utf-16-le") + b"\x00\x00"
set_value(h, node, name, REG_EXPAND_SZ, data)
elif action == "set_multi":
parts = op["value"]
data = b"".join(s.encode("utf-16-le") + b"\x00\x00" for s in parts) + b"\x00\x00"
set_value(h, node, name, REG_MULTI_SZ, data)
elif action == "set_binary":
data = bytes.fromhex(op["value_hex"])
set_value(h, node, name, REG_BINARY, data)
else:
raise ValueError(f"unknown action: {action}")

return f" set {path}\\{name}"


def main():
if len(sys.argv) != 2:
sys.exit("usage: tiny11_hive.py <hive_path> (ops on stdin as JSON list)")

hive_path = sys.argv[1]
if not os.path.exists(hive_path):
sys.exit(f"hive not found: {hive_path}")

ops = json.load(sys.stdin)
if not isinstance(ops, list):
sys.exit("expected a JSON list of ops on stdin")

print(f"hive: {hive_path} ({len(ops)} ops)", file=sys.stderr)

h = open_hive(hive_path)
try:
for op in ops:
try:
msg = apply_op(h, op)
if msg:
print(msg, file=sys.stderr)
except RuntimeError as e:
print(f" WARN: {e} op={op}", file=sys.stderr)
commit_hive(h)
finally:
close_hive(h)

print(f"hive: {hive_path} committed", file=sys.stderr)


if __name__ == "__main__":
main()
Loading