@wangyaoshen/remux 0.3.8-dev.bab6c95 → 0.3.10-dev.19fb76c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.github/workflows/publish.yml +191 -17
  2. package/apps/ios/Remux.xcodeproj/project.pbxproj +21 -0
  3. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  4. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  5. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  6. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  7. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  8. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  9. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  10. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  11. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  12. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  13. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  14. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  15. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  16. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  17. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  18. package/apps/ios/Sources/Remux/RootView.swift +2 -2
  19. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +11 -4
  20. package/apps/macos/Package.swift +5 -0
  21. package/apps/macos/Sources/Remux/AppCommand.swift +114 -0
  22. package/apps/macos/Sources/Remux/AppDelegate.swift +26 -0
  23. package/apps/macos/Sources/Remux/MainContentView.swift +56 -0
  24. package/apps/macos/Sources/Remux/MenuBarManager.swift +18 -26
  25. package/apps/macos/Sources/Remux/NotificationManager.swift +52 -7
  26. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +4 -8
  27. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +1 -1
  28. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +10 -4
  29. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +35 -5
  30. package/apps/macos/Sources/Remux/WindowObserver.swift +38 -0
  31. package/apps/macos/Tests/RemuxTests/AppCommandTests.swift +30 -0
  32. package/apps/macos/Tests/RemuxTests/NotificationManagerTests.swift +28 -0
  33. package/package.json +1 -1
  34. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +64 -0
  35. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +88 -9
  36. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +47 -8
  37. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +81 -8
  38. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +20 -1
  39. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +16 -0
  40. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +26 -0
  41. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +41 -7
  42. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +20 -2
  43. package/pty-daemon.js +17 -11
  44. package/scripts/setup-ci-secrets.sh +80 -0
  45. package/scripts/upload-testflight.sh +100 -0
  46. package/server.js +146 -872
  47. package/src/pty-daemon.ts +17 -11
  48. package/src/server.ts +96 -859
  49. package/src/session.ts +42 -4
  50. package/tests/auth.test.js +1 -1
  51. package/tests/e2e/app.spec.js +44 -4
  52. package/tests/pty-daemon.test.js +20 -1
  53. package/tests/server.test.js +50 -12
  54. package/vitest.config.js +1 -0
package/src/session.ts CHANGED
@@ -160,6 +160,34 @@ function getShell(): string {
160
160
  return "/bin/bash";
161
161
  }
162
162
 
163
+ function formatPtyError(error: unknown): string {
164
+ if (error instanceof Error && error.message) return error.message;
165
+ if (typeof error === "string" && error) return error;
166
+ return "unknown PTY error";
167
+ }
168
+
169
+ function markTabUnavailable(
170
+ tab: Tab,
171
+ sessionName: string,
172
+ error: unknown,
173
+ ): void {
174
+ const reason = formatPtyError(error);
175
+ const banner =
176
+ `\r\n\x1b[31mShell unavailable on this machine: ${reason}\x1b[0m\r\n`;
177
+
178
+ tab.ended = true;
179
+ tab.pty = null;
180
+ tab.daemonSocket = null;
181
+ tab.daemonClient = null;
182
+ tab.restored = false;
183
+ tab.scrollback.write(banner);
184
+ if (tab.vt) tab.vt.consume(banner);
185
+
186
+ console.error(
187
+ `[tab] shell unavailable for id=${tab.id} in session "${sessionName}": ${reason}`,
188
+ );
189
+ }
190
+
163
191
  // ── State ────────────────────────────────────────────────────────
164
192
 
165
193
  let tabIdCounter = 0;
@@ -353,6 +381,8 @@ export async function reviveTab(tab: Tab, session: Session): Promise<boolean> {
353
381
  return true;
354
382
  } catch (err: any) {
355
383
  console.error(`[session] failed to revive tab ${tab.id}:`, err.message);
384
+ markTabUnavailable(tab, session.name, err);
385
+ broadcastState();
356
386
  return false;
357
387
  }
358
388
  }
@@ -501,12 +531,20 @@ export function createTab(
501
531
  console.log(`[tab] daemon connected for id=${id} in session "${session.name}"`);
502
532
  }).catch((err) => {
503
533
  console.error(`[tab] failed to connect to daemon for id=${id}:`, err.message);
504
- // Fallback: spawn direct PTY
505
- spawnDirectPty(tab, session, shell, cols, rows);
534
+ try {
535
+ // Fallback: spawn direct PTY
536
+ spawnDirectPty(tab, session, shell, cols, rows);
537
+ } catch (spawnError) {
538
+ markTabUnavailable(tab, session.name, spawnError);
539
+ }
506
540
  });
507
541
  } else {
508
- // Direct PTY mode (fallback when daemon script not available)
509
- spawnDirectPty(tab, session, shell, cols, rows);
542
+ try {
543
+ // Direct PTY mode (fallback when daemon script not available)
544
+ spawnDirectPty(tab, session, shell, cols, rows);
545
+ } catch (err) {
546
+ markTabUnavailable(tab, session.name, err);
547
+ }
510
548
  }
511
549
 
512
550
  session.tabs.push(tab);
@@ -100,7 +100,7 @@ function startServer(env, port) {
100
100
  const cleanEnv = { ...process.env };
101
101
  delete cleanEnv.REMUX_TOKEN;
102
102
  delete cleanEnv.REMUX_PASSWORD;
103
- const proc = spawn("node", [SERVER_JS], {
103
+ const proc = spawn(process.execPath, [SERVER_JS], {
104
104
  env: { ...cleanEnv, ...env, PORT: String(port) },
105
105
  stdio: "pipe",
106
106
  });
@@ -76,6 +76,11 @@ test.describe.serial("Remux E2E", () => {
76
76
  // Sidebar should show "main" session
77
77
  const sessionItem = page.locator(".session-item .name", { hasText: "main" });
78
78
  await expect(sessionItem).toBeVisible();
79
+
80
+ await expect(page.locator("#btn-inspect")).toBeVisible();
81
+ await expect(page.locator("#btn-workspace")).toHaveCount(0);
82
+ await expect(page.locator("#devices-section")).toHaveCount(0);
83
+ await expect(page.locator("#push-section")).toHaveCount(0);
79
84
  });
80
85
 
81
86
  // ── 2. Live terminal interaction ──
@@ -174,11 +179,10 @@ test.describe.serial("Remux E2E", () => {
174
179
  { timeout: 10000 },
175
180
  );
176
181
 
177
- // Listen for the prompt dialog and accept with session name
178
- page.on("dialog", (d) => d.accept("test-e2e-session"));
179
-
180
- // Click "+" to create a new session
182
+ // Open the inline composer and create a session
181
183
  await page.locator("#btn-new-session").click();
184
+ await page.locator("#new-session-input").fill("test-e2e-session");
185
+ await page.locator("#btn-create-session").click();
182
186
 
183
187
  // Wait for the new session to appear in sidebar
184
188
  const newSession = page.locator(".session-item .name", {
@@ -319,6 +323,42 @@ test.describe.serial("Remux E2E", () => {
319
323
  }
320
324
  });
321
325
 
326
+ test("mobile chrome keeps control button visible and hides compose bar outside live", async ({
327
+ browser,
328
+ }) => {
329
+ const context = await browser.newContext({
330
+ hasTouch: true,
331
+ isMobile: true,
332
+ viewport: { width: 390, height: 664 },
333
+ });
334
+ const page = await context.newPage();
335
+
336
+ try {
337
+ await page.goto(BASE);
338
+ await expect(page.locator("#terminal canvas")).toBeVisible({
339
+ timeout: 10000,
340
+ });
341
+ await page.waitForFunction(
342
+ () =>
343
+ window._remuxTerm &&
344
+ document.querySelector("#status-dot")?.classList.contains("connected"),
345
+ { timeout: 10000 },
346
+ );
347
+
348
+ await expect(page.locator("#btn-role")).toBeVisible();
349
+ await expect(page.locator("#devices-section")).toHaveCount(0);
350
+ await expect(page.locator("#compose-bar")).toHaveClass(/visible/);
351
+
352
+ await page.locator("#btn-inspect").click();
353
+ await expect(page.locator("#inspect")).toHaveClass(/visible/, {
354
+ timeout: 5000,
355
+ });
356
+ await expect(page.locator("#compose-bar")).not.toHaveClass(/visible/);
357
+ } finally {
358
+ await context.close();
359
+ }
360
+ });
361
+
322
362
  test("IME composition ignores viewport shrink on touch devices", async ({
323
363
  browser,
324
364
  }) => {
@@ -7,6 +7,7 @@ import net from "net";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
9
  import { fileURLToPath } from "url";
10
+ import pty from "node-pty";
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  const DAEMON_SCRIPT = path.join(__dirname, "..", "pty-daemon.js");
@@ -21,6 +22,24 @@ const TAG_SNAPSHOT_REQ = 0x06;
21
22
  const TAG_SNAPSHOT_RES = 0x07;
22
23
  const TAG_SHUTDOWN = 0xff;
23
24
 
25
+ function supportsNodePty() {
26
+ try {
27
+ const proc = pty.spawn("/bin/sh", ["-lc", "exit 0"], {
28
+ name: "xterm-256color",
29
+ cols: 80,
30
+ rows: 24,
31
+ cwd: "/tmp",
32
+ env: process.env,
33
+ });
34
+ proc.kill();
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ const describePtyLifecycle = supportsNodePty() ? describe : describe.skip;
42
+
24
43
  function encodeFrame(tag, payload) {
25
44
  const data =
26
45
  typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
@@ -137,7 +156,7 @@ describe("TLV Frame Codec", () => {
137
156
  });
138
157
  });
139
158
 
140
- describe("PTY Daemon Lifecycle", () => {
159
+ describePtyLifecycle("PTY Daemon Lifecycle", () => {
141
160
  let daemon;
142
161
  let socketPath;
143
162
 
@@ -11,8 +11,9 @@ import WebSocket from "ws";
11
11
  import fs from "fs";
12
12
  import path from "path";
13
13
  import { homedir } from "os";
14
+ import pty from "node-pty";
14
15
 
15
- const PORT = 19876; // test-only port
16
+ const PORT = 19876 + Math.floor(Math.random() * 1000); // randomized test port
16
17
  const TOKEN = "test-token-" + Date.now();
17
18
  const INSTANCE_ID = "test-" + Date.now();
18
19
  const PERSIST_DIR = path.join(homedir(), ".remux");
@@ -20,6 +21,24 @@ const PERSIST_FILE = path.join(PERSIST_DIR, `sessions-${INSTANCE_ID}.json`);
20
21
  const DB_FILE = path.join(PERSIST_DIR, `remux-${INSTANCE_ID}.db`);
21
22
  let serverProc;
22
23
 
24
+ function supportsNodePty() {
25
+ try {
26
+ const proc = pty.spawn("/bin/sh", ["-lc", "exit 0"], {
27
+ name: "xterm-256color",
28
+ cols: 80,
29
+ rows: 24,
30
+ cwd: "/tmp",
31
+ env: process.env,
32
+ });
33
+ proc.kill();
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ const PTY_SUPPORTED = supportsNodePty();
41
+
23
42
  function httpGet(urlPath) {
24
43
  return new Promise((resolve, reject) => {
25
44
  http.get(`http://localhost:${PORT}${urlPath}`, (res) => {
@@ -113,7 +132,7 @@ beforeAll(async () => {
113
132
  // Explicitly remove REMUX_PASSWORD to avoid env leaking from parent
114
133
  const cleanEnv = { ...process.env };
115
134
  delete cleanEnv.REMUX_PASSWORD;
116
- serverProc = spawn("node", ["server.js"], {
135
+ serverProc = spawn(process.execPath, ["server.js"], {
117
136
  env: {
118
137
  ...cleanEnv,
119
138
  PORT: String(PORT),
@@ -185,6 +204,15 @@ describe("HTTP", () => {
185
204
  expect(res.body).toContain("<title>Remux</title>");
186
205
  });
187
206
 
207
+ it("serves reduced shell without workspace or device chrome", async () => {
208
+ const res = await httpGet(`/?token=${TOKEN}`);
209
+ expect(res.status).toBe(200);
210
+ expect(res.body).toContain("Inspect");
211
+ expect(res.body).not.toContain("Workspace");
212
+ expect(res.body).not.toContain("Devices");
213
+ expect(res.body).not.toContain("Enable Notifications");
214
+ });
215
+
188
216
  it("serves ghostty-web JS", async () => {
189
217
  const res = await httpGet("/dist/ghostty-web.js");
190
218
  expect(res.status).toBe(200);
@@ -513,16 +541,18 @@ describe("client connection state", () => {
513
541
  const ws2 = await connectAuthed();
514
542
 
515
543
  // ws1 attaches first (active)
516
- await sendAndCollect(
544
+ const msgs1 = await sendAndCollect(
517
545
  ws1,
518
546
  { type: "attach_first", session: "main", cols: 80, rows: 24 },
519
547
  { timeout: 3000 },
520
548
  );
549
+ const att1 = msgs1.find((m) => m.type === "attached");
550
+ expect(att1).toBeDefined();
521
551
 
522
552
  // ws2 attaches to same tab (observer)
523
553
  const msgs2 = await sendAndCollect(
524
554
  ws2,
525
- { type: "attach_first", session: "main", cols: 80, rows: 24 },
555
+ { type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
526
556
  { timeout: 3000 },
527
557
  );
528
558
  const att2 = msgs2.find((m) => m.type === "attached");
@@ -552,11 +582,12 @@ describe("client connection state", () => {
552
582
  const att1 = msgs1.find((m) => m.type === "attached");
553
583
  expect(att1.role).toBe("active");
554
584
  const clientId1 = att1.clientId;
585
+ const tabId = att1.tabId;
555
586
 
556
587
  // ws2 attaches (observer)
557
588
  const msgs2 = await sendAndCollect(
558
589
  ws2,
559
- { type: "attach_first", session: "main", cols: 80, rows: 24 },
590
+ { type: "attach_tab", tabId, cols: 80, rows: 24 },
560
591
  { timeout: 3000 },
561
592
  );
562
593
  const att2 = msgs2.find((m) => m.type === "attached");
@@ -617,11 +648,12 @@ describe("client connection state", () => {
617
648
  const att1 = msgs1.find((m) => m.type === "attached");
618
649
  expect(att1.role).toBe("active");
619
650
  const clientId1 = att1.clientId;
651
+ const tabId = att1.tabId;
620
652
 
621
653
  // ws2 attaches (observer)
622
654
  const msgs2 = await sendAndCollect(
623
655
  ws2,
624
- { type: "attach_first", session: "main", cols: 80, rows: 24 },
656
+ { type: "attach_tab", tabId, cols: 80, rows: 24 },
625
657
  { timeout: 3000 },
626
658
  );
627
659
  const att2 = msgs2.find((m) => m.type === "attached");
@@ -669,16 +701,18 @@ describe("client connection state", () => {
669
701
  const ws2 = await connectAuthed();
670
702
 
671
703
  // ws1 attaches (active)
672
- await sendAndCollect(
704
+ const msgs1 = await sendAndCollect(
673
705
  ws1,
674
706
  { type: "attach_first", session: "main", cols: 80, rows: 24 },
675
707
  { timeout: 3000 },
676
708
  );
709
+ const att1 = msgs1.find((m) => m.type === "attached");
710
+ expect(att1).toBeDefined();
677
711
 
678
712
  // ws2 attaches (observer)
679
713
  const msgs2 = await sendAndCollect(
680
714
  ws2,
681
- { type: "attach_first", session: "main", cols: 80, rows: 24 },
715
+ { type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
682
716
  { timeout: 3000 },
683
717
  );
684
718
  const att2 = msgs2.find((m) => m.type === "attached");
@@ -720,15 +754,15 @@ describe("multi-client", () => {
720
754
  { type: "attach_first", session: "main", cols: 100, rows: 30 },
721
755
  { timeout: 3000 },
722
756
  );
757
+ const att1 = msgs1.find((m) => m.type === "attached");
758
+ expect(att1).toBeDefined();
723
759
  const msgs2 = await sendAndCollect(
724
760
  ws2,
725
- { type: "attach_first", session: "main", cols: 80, rows: 24 },
761
+ { type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
726
762
  { timeout: 3000 },
727
763
  );
728
764
 
729
- const att1 = msgs1.find((m) => m.type === "attached");
730
765
  const att2 = msgs2.find((m) => m.type === "attached");
731
- expect(att1).toBeDefined();
732
766
  expect(att2).toBeDefined();
733
767
  expect(att1.tabId).toBe(att2.tabId);
734
768
 
@@ -840,7 +874,11 @@ describe("inspect", () => {
840
874
  const result = msgs.find((m) => m.type === "inspect_result");
841
875
  expect(result).toBeDefined();
842
876
  expect(typeof result.text).toBe("string");
843
- expect(result.text).toContain("inspect-test-output");
877
+ if (PTY_SUPPORTED) {
878
+ expect(result.text).toContain("inspect-test-output");
879
+ } else {
880
+ expect(result.text).toContain("Shell unavailable on this machine");
881
+ }
844
882
  expect(result.meta).toBeDefined();
845
883
  expect(result.meta.session).toBe("main");
846
884
  expect(typeof result.meta.cols).toBe("number");
package/vitest.config.js CHANGED
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ fileParallelism: false,
5
6
  testTimeout: 20000,
6
7
  hookTimeout: 40000,
7
8
  include: ["tests/**/*.test.{js,ts}"],