Skip to content

Commit 27b6a10

Browse files
authored
🤖 fix: add cross-platform path handling for Windows compatibility (#540)
Adds comprehensive cross-platform path handling to support Windows builds alongside Unix systems. ## Changes - **New PlatformPaths utility class** - Single source of truth for all path operations - Handles Windows drive letters (C:\) and UNC paths (\\server\share) - Correctly splits paths using both forward and backslash separators - Platform-aware home directory expansion (~/ on Unix, %USERPROFILE% on Windows) - Fish-style path abbreviation that works on both platforms - **Replaced all direct path operations** throughout the codebase: - `path.basename()` → `PlatformPaths.basename()` - `expandTilde()` → `PlatformPaths.expandHome()` - `abbreviatePath()` → `PlatformPaths.abbreviate()` - Manual path splitting → `PlatformPaths.parse()` - **Comprehensive test coverage** - Tests verify correct behavior on both platforms ## Testing - All existing tests pass - New PlatformPaths tests verify Windows and Unix path handling - Type checking passes _Generated with `mux`_
1 parent 9572334 commit 27b6a10

28 files changed

+976
-211
lines changed

.github/actions/setup-mux/action.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,14 @@ runs:
6262
sudo apt-get install -y --no-install-recommends imagemagick
6363
fi
6464
convert --version | head -1
65+
- name: Install ImageMagick (Windows)
66+
if: inputs.install-imagemagick == 'true' && runner.os == 'Windows'
67+
shell: powershell
68+
run: |
69+
if (Get-Command magick -ErrorAction SilentlyContinue) {
70+
Write-Host "✅ ImageMagick already available"
71+
} else {
72+
Write-Host "📦 Installing ImageMagick..."
73+
choco install -y imagemagick
74+
}
75+
magick --version | Select-Object -First 1

.github/workflows/release.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,34 @@ jobs:
7979
gh release upload ${{ github.event.release.tag_name }} \
8080
vscode/cmux-*.vsix \
8181
--clobber
82+
83+
build-windows:
84+
name: Build and Release Windows
85+
runs-on: windows-latest
86+
steps:
87+
- name: Checkout code
88+
uses: actions/checkout@v4
89+
with:
90+
fetch-depth: 0 # Required for git describe to find tags
91+
92+
- uses: ./.github/actions/setup-cmux
93+
with:
94+
install-imagemagick: true
95+
96+
- name: Install GNU Make (for build)
97+
run: choco install -y make
98+
99+
- name: Verify tools
100+
shell: bash
101+
run: |
102+
make --version
103+
bun --version
104+
magick --version | head -1
105+
106+
- name: Build application
107+
run: bun run build
108+
109+
- name: Package and publish for Windows (.exe)
110+
run: bun x electron-builder --win --publish always
111+
env:
112+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Makefile

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@
2424
# Branches reduce reproducibility - builds should fail fast with clear errors
2525
# if dependencies are missing, not silently fall back to different behavior.
2626

27+
# Use PATH-resolved bash on Windows to avoid hardcoded /usr/bin/bash which doesn't
28+
# exist in Chocolatey's make environment or on GitHub Actions windows-latest.
29+
ifeq ($(OS),Windows_NT)
30+
SHELL := bash
31+
else
32+
SHELL := /bin/bash
33+
endif
34+
.SHELLFLAGS := -eu -o pipefail -c
35+
2736
# Enable parallel execution by default (only if user didn't specify -j)
2837
ifeq (,$(filter -j%,$(MAKEFLAGS)))
2938
MAKEFLAGS += -j
@@ -84,10 +93,6 @@ node_modules/.installed: package.json bun.lock
8493
# Legacy target for backwards compatibility
8594
ensure-deps: node_modules/.installed
8695

87-
88-
89-
90-
9196
## Help
9297
help: ## Show this help message
9398
@echo 'Usage: make [target]'
@@ -96,11 +101,34 @@ help: ## Show this help message
96101
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
97102

98103
## Development
104+
ifeq ($(OS),Windows_NT)
105+
dev: node_modules/.installed build-main ## Start development server (Vite + nodemon watcher for Windows compatibility)
106+
@echo "Starting dev mode (2 watchers: nodemon for main process, vite for renderer)..."
107+
# On Windows, use npm run because bunx doesn't correctly pass arguments to concurrently
108+
# https://github.com/oven-sh/bun/issues/18275
109+
@NODE_OPTIONS="--max-old-space-size=4096" npm x concurrently -k --raw \
110+
"bun x nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
111+
"vite"
112+
else
99113
dev: node_modules/.installed build-main ## Start development server (Vite + tsgo watcher for 10x faster type checking)
100114
@bun x concurrently -k \
101115
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
102116
"vite"
117+
endif
103118

119+
ifeq ($(OS),Windows_NT)
120+
dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access
121+
@echo "Starting dev-server..."
122+
@echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)"
123+
@echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)"
124+
@echo ""
125+
@echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0"
126+
@# On Windows, use npm run because bunx doesn't correctly pass arguments
127+
@npmx concurrently -k \
128+
"npmx nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
129+
"npmx nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec \"node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \
130+
"$(SHELL) -lc \"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\""
131+
else
104132
dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access
105133
@echo "Starting dev-server..."
106134
@echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)"
@@ -111,6 +139,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel
111139
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
112140
"bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'NODE_ENV=development node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \
113141
"MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) MUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite"
142+
endif
114143

115144

116145

@@ -167,16 +196,16 @@ MAGICK_CMD := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev
167196
build/icon.png: docs/img/logo.webp
168197
@echo "Generating Linux icon..."
169198
@mkdir -p build
170-
@$(MAGICK_CMD) docs/img/logo.webp -resize 512x512 build/icon.png
199+
@"$(MAGICK_CMD)" docs/img/logo.webp -resize 512x512 build/icon.png
171200

172201
build/icon.icns: docs/img/logo.webp
173202
@echo "Generating macOS icon..."
174203
@mkdir -p build/icon.iconset
175204
@for size in 16 32 64 128 256 512; do \
176-
$(MAGICK_CMD) docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \
205+
"$(MAGICK_CMD)" docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \
177206
if [ $$size -le 256 ]; then \
178207
double=$$((size * 2)); \
179-
$(MAGICK_CMD) docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \
208+
"$(MAGICK_CMD)" docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \
180209
fi; \
181210
done
182211
@iconutil -c icns build/icon.iconset -o build/icon.icns
@@ -191,10 +220,18 @@ lint: node_modules/.installed ## Run ESLint (typecheck runs in separate target)
191220
lint-fix: node_modules/.installed ## Run linter with --fix
192221
@./scripts/lint.sh --fix
193222

223+
ifeq ($(OS),Windows_NT)
194224
typecheck: node_modules/.installed src/version.ts ## Run TypeScript type checking (uses tsgo for 10x speedup)
225+
@# On Windows, use npm run because bun x doesn't correctly pass arguments
226+
@npmx concurrently -g \
227+
"$(TSGO) --noEmit" \
228+
"$(TSGO) --noEmit -p tsconfig.main.json"
229+
else
230+
typecheck: node_modules/.installed src/version.ts
195231
@bun x concurrently -g \
196232
"$(TSGO) --noEmit" \
197233
"$(TSGO) --noEmit -p tsconfig.main.json"
234+
endif
198235

199236
check-deadcode: node_modules/.installed ## Check for potential dead code (manual only, not in static-check)
200237
@echo "Checking for potential dead code with ts-prune..."

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@
205205
"artifactName": "${productName}-${version}-${arch}.${ext}"
206206
},
207207
"win": {
208-
"target": "nsis"
208+
"target": "nsis",
209+
"icon": "build/icon.png",
210+
"artifactName": "${productName}-${version}-${arch}.${ext}"
209211
}
210212
}
211213
}

public/service-worker.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,20 @@ self.addEventListener("fetch", (event) => {
5151
})
5252
.catch(() => {
5353
// If network fails, try cache
54-
return caches.match(event.request);
54+
return caches.match(event.request).then((cachedResponse) => {
55+
// If cache has it, return it; otherwise return a proper error response
56+
if (cachedResponse) {
57+
return cachedResponse;
58+
}
59+
// Return a proper Response object for failed requests
60+
return new Response("Network error and no cached version available", {
61+
status: 503,
62+
statusText: "Service Unavailable",
63+
headers: new Headers({
64+
"Content-Type": "text/plain",
65+
}),
66+
});
67+
});
5568
})
5669
);
5770
});

scripts/build-main-watch.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Build script for main process in watch mode
4+
* Used by nodemon - ignores file arguments passed by nodemon
5+
*/
6+
7+
const { execSync } = require('child_process');
8+
const path = require('path');
9+
10+
const rootDir = path.join(__dirname, '..');
11+
const tsgoPath = path.join(rootDir, 'node_modules/@typescript/native-preview/bin/tsgo.js');
12+
const tscAliasPath = path.join(rootDir, 'node_modules/tsc-alias/dist/bin/index.js');
13+
14+
try {
15+
console.log('Building main process...');
16+
17+
// Run tsgo
18+
execSync(`node "${tsgoPath}" -p tsconfig.main.json`, {
19+
cwd: rootDir,
20+
stdio: 'inherit',
21+
env: { ...process.env, NODE_ENV: 'development' }
22+
});
23+
24+
// Run tsc-alias
25+
execSync(`node "${tscAliasPath}" -p tsconfig.main.json`, {
26+
cwd: rootDir,
27+
stdio: 'inherit',
28+
env: { ...process.env, NODE_ENV: 'development' }
29+
});
30+
31+
console.log('✓ Main process build complete');
32+
} catch (error) {
33+
console.error('Build failed:', error.message);
34+
process.exit(1);
35+
}
36+

src/components/ProjectSidebar.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend";
99
import { useDrag, useDrop, useDragLayer } from "react-dnd";
1010
import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering";
1111
import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
12-
import { abbreviatePath, splitAbbreviatedPath } from "@/utils/ui/pathAbbreviation";
12+
import { PlatformPaths } from "@/utils/paths";
1313
import {
1414
partitionWorkspacesByAge,
1515
formatOldWorkspaceThreshold,
@@ -131,8 +131,8 @@ const ProjectDragLayer: React.FC = () => {
131131

132132
if (!isDragging || !currentOffset || !item?.projectPath) return null;
133133

134-
const abbrevPath = abbreviatePath(item.projectPath);
135-
const { dirPath, basename } = splitAbbreviatedPath(abbrevPath);
134+
const abbrevPath = PlatformPaths.abbreviate(item.projectPath);
135+
const { dirPath, basename } = PlatformPaths.splitAbbreviated(abbrevPath);
136136

137137
return (
138138
<div className="pointer-events-none fixed inset-0 z-[9999] cursor-grabbing">
@@ -238,7 +238,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
238238
if (!path || typeof path !== "string") {
239239
return "Unknown";
240240
}
241-
return path.split("/").pop() ?? path.split("\\").pop() ?? path;
241+
return PlatformPaths.getProjectName(path);
242242
};
243243

244244
const toggleProject = (projectPath: string) => {
@@ -498,8 +498,9 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
498498
<TooltipWrapper inline>
499499
<div className="text-muted-dark font-monospace truncate text-sm leading-tight">
500500
{(() => {
501-
const abbrevPath = abbreviatePath(projectPath);
502-
const { dirPath, basename } = splitAbbreviatedPath(abbrevPath);
501+
const abbrevPath = PlatformPaths.abbreviate(projectPath);
502+
const { dirPath, basename } =
503+
PlatformPaths.splitAbbreviated(abbrevPath);
503504
return (
504505
<>
505506
<span>{dirPath}</span>

src/config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Secret, SecretsConfig } from "./types/secrets";
88
import type { Workspace, ProjectConfig, ProjectsConfig } from "./types/project";
99
import { DEFAULT_RUNTIME_CONFIG } from "./constants/workspace";
1010
import { getMuxHome } from "./constants/paths";
11+
import { PlatformPaths } from "./utils/paths";
1112

1213
// Re-export project types from dedicated types file (for preload usage)
1314
export type { Workspace, ProjectConfig, ProjectsConfig };
@@ -96,7 +97,7 @@ export class Config {
9697
}
9798

9899
private getProjectName(projectPath: string): string {
99-
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
100+
return PlatformPaths.getProjectName(projectPath);
100101
}
101102

102103
/**
@@ -120,8 +121,7 @@ export class Config {
120121
*/
121122
generateLegacyId(projectPath: string, workspacePath: string): string {
122123
const projectBasename = this.getProjectName(projectPath);
123-
const workspaceBasename =
124-
workspacePath.split("/").pop() ?? workspacePath.split("\\").pop() ?? "unknown";
124+
const workspaceBasename = PlatformPaths.basename(workspacePath);
125125
return `${projectBasename}-${workspaceBasename}`;
126126
}
127127

src/debug/agentSessionCli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import assert from "@/utils/assert";
44
import * as fs from "fs/promises";
55
import * as path from "path";
6+
import { PlatformPaths } from "../utils/paths";
67
import { parseArgs } from "util";
78
import { Config } from "@/config";
89
import { HistoryService } from "@/services/historyService";
@@ -168,8 +169,8 @@ async function main(): Promise<void> {
168169
const projectPathRaw = values["project-path"];
169170
const projectName =
170171
typeof projectPathRaw === "string" && projectPathRaw.trim().length > 0
171-
? path.basename(path.resolve(projectPathRaw.trim()))
172-
: path.basename(path.dirname(workspacePath)) || "unknown";
172+
? PlatformPaths.basename(path.resolve(projectPathRaw.trim()))
173+
: PlatformPaths.basename(path.dirname(workspacePath)) || "unknown";
173174

174175
const messageArg =
175176
values.message && values.message.trim().length > 0 ? values.message : undefined;

src/debug/list-workspaces.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defaultConfig } from "@/config";
2-
import * as path from "path";
2+
import { PlatformPaths } from "../utils/paths";
33
import * as fs from "fs";
44
import { getMuxSessionsDir } from "@/constants/paths";
55

@@ -10,13 +10,13 @@ export function listWorkspacesCommand() {
1010
console.log("Projects in config:", config.projects.size);
1111

1212
for (const [projectPath, project] of config.projects) {
13-
const projectName = path.basename(projectPath);
13+
const projectName = PlatformPaths.basename(projectPath);
1414
console.log(`\nProject: ${projectName}`);
1515
console.log(` Path: ${projectPath}`);
1616
console.log(` Workspaces: ${project.workspaces.length}`);
1717

1818
for (const workspace of project.workspaces) {
19-
const dirName = path.basename(workspace.path);
19+
const dirName = PlatformPaths.basename(workspace.path);
2020
console.log(` - Directory: ${dirName}`);
2121
if (workspace.id) {
2222
console.log(` ID: ${workspace.id}`);

0 commit comments

Comments
 (0)