tabctl 0.2.1 → 0.3.1

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.
@@ -0,0 +1,3 @@
1
+ module github.com/ekroon/tabctl/host-launcher
2
+
3
+ go 1.21
@@ -0,0 +1,109 @@
1
+ // Tiny native messaging host launcher for Windows.
2
+ //
3
+ // Chrome launches native messaging hosts as sub-processes. On Windows, if the
4
+ // host is a .cmd/.bat file, Chrome invokes it through cmd.exe which opens
5
+ // stdin/stdout in text mode — corrupting the binary 4-byte length-prefixed
6
+ // native messaging protocol.
7
+ //
8
+ // This launcher reads a config file (host-launcher.cfg) next to the exe,
9
+ // sets environment variables, and proxies stdin/stdout between Chrome and
10
+ // Node.js in binary mode.
11
+ //
12
+ // Build: GOOS=windows GOARCH=amd64 go build -o tabctl-host.exe .
13
+
14
+ package main
15
+
16
+ import (
17
+ "bufio"
18
+ "fmt"
19
+ "io"
20
+ "os"
21
+ "os/exec"
22
+ "path/filepath"
23
+ "strings"
24
+ "sync"
25
+ )
26
+
27
+ func main() {
28
+ exePath, err := os.Executable()
29
+ if err != nil {
30
+ fmt.Fprintf(os.Stderr, "tabctl-host: cannot resolve exe path: %v\n", err)
31
+ os.Exit(1)
32
+ }
33
+ exeDir := filepath.Dir(exePath)
34
+ cfgPath := filepath.Join(exeDir, "host-launcher.cfg")
35
+
36
+ f, err := os.Open(cfgPath)
37
+ if err != nil {
38
+ fmt.Fprintf(os.Stderr, "tabctl-host: cannot open %s: %v\n", cfgPath, err)
39
+ os.Exit(1)
40
+ }
41
+
42
+ scanner := bufio.NewScanner(f)
43
+
44
+ if !scanner.Scan() {
45
+ fmt.Fprintf(os.Stderr, "tabctl-host: missing node path in %s\n", cfgPath)
46
+ os.Exit(1)
47
+ }
48
+ nodePath := strings.TrimSpace(scanner.Text())
49
+
50
+ if !scanner.Scan() {
51
+ fmt.Fprintf(os.Stderr, "tabctl-host: missing host path in %s\n", cfgPath)
52
+ os.Exit(1)
53
+ }
54
+ hostPath := strings.TrimSpace(scanner.Text())
55
+
56
+ for scanner.Scan() {
57
+ line := strings.TrimSpace(scanner.Text())
58
+ if line == "" {
59
+ continue
60
+ }
61
+ if eq := strings.IndexByte(line, '='); eq > 0 {
62
+ os.Setenv(line[:eq], line[eq+1:])
63
+ }
64
+ }
65
+ f.Close()
66
+
67
+ // Proxy stdin/stdout via pipes to preserve binary mode.
68
+ cmd := exec.Command(nodePath, hostPath)
69
+ cmd.Stderr = os.Stderr
70
+
71
+ nodeIn, err := cmd.StdinPipe()
72
+ if err != nil {
73
+ fmt.Fprintf(os.Stderr, "tabctl-host: stdin pipe: %v\n", err)
74
+ os.Exit(1)
75
+ }
76
+ nodeOut, err := cmd.StdoutPipe()
77
+ if err != nil {
78
+ fmt.Fprintf(os.Stderr, "tabctl-host: stdout pipe: %v\n", err)
79
+ os.Exit(1)
80
+ }
81
+
82
+ if err := cmd.Start(); err != nil {
83
+ fmt.Fprintf(os.Stderr, "tabctl-host: failed to start: %v\n", err)
84
+ os.Exit(1)
85
+ }
86
+
87
+ var wg sync.WaitGroup
88
+ wg.Add(2)
89
+
90
+ go func() {
91
+ defer wg.Done()
92
+ io.Copy(nodeIn, os.Stdin)
93
+ nodeIn.Close()
94
+ }()
95
+
96
+ go func() {
97
+ defer wg.Done()
98
+ io.Copy(os.Stdout, nodeOut)
99
+ }()
100
+
101
+ wg.Wait()
102
+
103
+ if err := cmd.Wait(); err != nil {
104
+ if exitErr, ok := err.(*exec.ExitError); ok {
105
+ os.Exit(exitErr.ExitCode())
106
+ }
107
+ os.Exit(1)
108
+ }
109
+ }
@@ -3,12 +3,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveSocketPath = resolveSocketPath;
6
7
  exports.resetConfig = resetConfig;
7
8
  exports.expandEnvVars = expandEnvVars;
8
9
  exports.resolveConfig = resolveConfig;
9
10
  const os_1 = __importDefault(require("os"));
10
11
  const path_1 = __importDefault(require("path"));
12
+ const crypto_1 = __importDefault(require("crypto"));
11
13
  const fs_1 = __importDefault(require("fs"));
14
+ function defaultConfigBase() {
15
+ if (process.platform === "win32") {
16
+ return process.env.APPDATA || path_1.default.join(os_1.default.homedir(), "AppData", "Roaming");
17
+ }
18
+ return path_1.default.join(os_1.default.homedir(), ".config");
19
+ }
20
+ function defaultStateBase() {
21
+ if (process.platform === "win32") {
22
+ return process.env.LOCALAPPDATA || path_1.default.join(os_1.default.homedir(), "AppData", "Local");
23
+ }
24
+ return path_1.default.join(os_1.default.homedir(), ".local", "state");
25
+ }
26
+ /** Resolve the IPC socket/pipe path for the given data directory. */
27
+ function resolveSocketPath(dataDir) {
28
+ if (process.platform === "win32") {
29
+ // Windows: use named pipes (Unix domain sockets are unreliable)
30
+ const hash = crypto_1.default.createHash("sha256").update(dataDir).digest("hex").slice(0, 12);
31
+ return `\\\\.\\pipe\\tabctl-${hash}`;
32
+ }
33
+ return path_1.default.join(dataDir, "tabctl.sock");
34
+ }
12
35
  let cached;
13
36
  function resetConfig() {
14
37
  cached = undefined;
@@ -25,7 +48,7 @@ function resolveConfig(profileName) {
25
48
  return cached;
26
49
  // Config dir resolution
27
50
  const configDir = process.env.TABCTL_CONFIG_DIR
28
- || path_1.default.join(process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), ".config"), "tabctl");
51
+ || path_1.default.join(process.env.XDG_CONFIG_HOME || defaultConfigBase(), "tabctl");
29
52
  // Read optional config.json
30
53
  let fileConfig = {};
31
54
  try {
@@ -47,7 +70,7 @@ function resolveConfig(profileName) {
47
70
  dataDir = path_1.default.join(configDir, "data");
48
71
  }
49
72
  else {
50
- dataDir = path_1.default.join(process.env.XDG_STATE_HOME || path_1.default.join(os_1.default.homedir(), ".local", "state"), "tabctl");
73
+ dataDir = path_1.default.join(process.env.XDG_STATE_HOME || defaultStateBase(), "tabctl");
51
74
  }
52
75
  const baseDataDir = dataDir;
53
76
  // Profile resolution (read profiles.json inline to avoid circular import)
@@ -97,7 +120,7 @@ function resolveConfig(profileName) {
97
120
  configDir,
98
121
  dataDir,
99
122
  baseDataDir,
100
- socketPath: process.env.TABCTL_SOCKET || path_1.default.join(dataDir, "tabctl.sock"),
123
+ socketPath: process.env.TABCTL_SOCKET || resolveSocketPath(dataDir),
101
124
  undoLog: path_1.default.join(dataDir, "undo.jsonl"),
102
125
  wrapperDir: dataDir,
103
126
  policyPath: path_1.default.join(configDir, "policy.json"),
@@ -3,16 +3,31 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.EXTENSION_DIR_NAME = void 0;
6
+ exports.HOST_BUNDLE_NAME = exports.EXTENSION_DIR_NAME = void 0;
7
+ exports.deriveExtensionId = deriveExtensionId;
7
8
  exports.resolveBundledExtensionDir = resolveBundledExtensionDir;
9
+ exports.resolveBundledHostPath = resolveBundledHostPath;
8
10
  exports.resolveInstalledExtensionDir = resolveInstalledExtensionDir;
11
+ exports.resolveInstalledHostPath = resolveInstalledHostPath;
9
12
  exports.readExtensionVersion = readExtensionVersion;
13
+ exports.readHostVersion = readHostVersion;
10
14
  exports.syncExtension = syncExtension;
15
+ exports.syncHost = syncHost;
11
16
  exports.checkExtensionSync = checkExtensionSync;
12
17
  const path_1 = __importDefault(require("path"));
13
18
  const fs_1 = __importDefault(require("fs"));
19
+ const crypto_1 = __importDefault(require("crypto"));
14
20
  const config_1 = require("./config");
15
21
  exports.EXTENSION_DIR_NAME = "extension";
22
+ exports.HOST_BUNDLE_NAME = "host.bundle.js";
23
+ /**
24
+ * Derive the Chrome/Edge extension ID for an unpacked extension path.
25
+ * Chromium computes: SHA256(absolute_path) → first 32 hex chars → map 0-f to a-p.
26
+ */
27
+ function deriveExtensionId(extensionDir) {
28
+ const hash = crypto_1.default.createHash("sha256").update(extensionDir).digest("hex").slice(0, 32);
29
+ return hash.split("").map(c => String.fromCharCode("a".charCodeAt(0) + parseInt(c, 16))).join("");
30
+ }
16
31
  function resolveBundledExtensionDir() {
17
32
  const dir = path_1.default.resolve(__dirname, "../extension");
18
33
  const manifest = path_1.default.join(dir, "manifest.json");
@@ -21,10 +36,21 @@ function resolveBundledExtensionDir() {
21
36
  }
22
37
  return dir;
23
38
  }
39
+ function resolveBundledHostPath() {
40
+ const p = path_1.default.resolve(__dirname, "../host", exports.HOST_BUNDLE_NAME);
41
+ if (!fs_1.default.existsSync(p)) {
42
+ throw new Error(`Bundled host not found at ${p}`);
43
+ }
44
+ return p;
45
+ }
24
46
  function resolveInstalledExtensionDir(dataDir) {
25
- const dir = dataDir ?? (0, config_1.resolveConfig)().dataDir;
47
+ const dir = dataDir ?? (0, config_1.resolveConfig)().baseDataDir;
26
48
  return path_1.default.join(dir, exports.EXTENSION_DIR_NAME);
27
49
  }
50
+ function resolveInstalledHostPath(dataDir) {
51
+ const dir = dataDir ?? (0, config_1.resolveConfig)().baseDataDir;
52
+ return path_1.default.join(dir, exports.HOST_BUNDLE_NAME);
53
+ }
28
54
  function readExtensionVersion(extensionDir) {
29
55
  try {
30
56
  const raw = fs_1.default.readFileSync(path_1.default.join(extensionDir, "manifest.json"), "utf-8");
@@ -35,6 +61,17 @@ function readExtensionVersion(extensionDir) {
35
61
  return null;
36
62
  }
37
63
  }
64
+ /** Read the BASE_VERSION constant from a bundled host.bundle.js file. */
65
+ function readHostVersion(hostPath) {
66
+ try {
67
+ const content = fs_1.default.readFileSync(hostPath, "utf-8");
68
+ const match = content.match(/\bBASE_VERSION\s*=\s*"([^"]+)"/);
69
+ return match ? match[1] : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
38
75
  function syncExtension(dataDir) {
39
76
  const bundledDir = resolveBundledExtensionDir();
40
77
  const installedDir = resolveInstalledExtensionDir(dataDir);
@@ -52,6 +89,23 @@ function syncExtension(dataDir) {
52
89
  extensionDir: installedDir,
53
90
  };
54
91
  }
92
+ function syncHost(dataDir) {
93
+ const bundledPath = resolveBundledHostPath();
94
+ const installedPath = resolveInstalledHostPath(dataDir);
95
+ const bundledVersion = readHostVersion(bundledPath);
96
+ const installedVersion = readHostVersion(installedPath);
97
+ const needsCopy = !fs_1.default.existsSync(installedPath) || bundledVersion !== installedVersion;
98
+ if (needsCopy) {
99
+ fs_1.default.mkdirSync(path_1.default.dirname(installedPath), { recursive: true });
100
+ fs_1.default.copyFileSync(bundledPath, installedPath);
101
+ }
102
+ return {
103
+ synced: needsCopy,
104
+ bundledVersion,
105
+ installedVersion,
106
+ hostPath: installedPath,
107
+ };
108
+ }
55
109
  function checkExtensionSync(dataDir) {
56
110
  const bundledDir = resolveBundledExtensionDir();
57
111
  const installedDir = resolveInstalledExtensionDir(dataDir);
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DIRTY = exports.GIT_SHA = exports.VERSION = exports.BASE_VERSION = void 0;
4
- exports.BASE_VERSION = "0.2.1";
5
- exports.VERSION = "0.2.1";
4
+ exports.BASE_VERSION = "0.3.1";
5
+ exports.VERSION = "0.3.1";
6
6
  exports.GIT_SHA = null;
7
7
  exports.DIRTY = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,19 +27,22 @@
27
27
  "dist/extension"
28
28
  ],
29
29
  "scripts": {
30
- "build": "node scripts/gen-version.js && tsc -p tsconfig.json && node scripts/copy-artifacts.js && node scripts/bundle-extension.js",
30
+ "build": "node scripts/gen-version.js && tsc -p tsconfig.json && node scripts/copy-artifacts.js && node scripts/build-launcher.js && node scripts/bundle-extension.js",
31
31
  "bump:major": "node scripts/bump-version.js major",
32
32
  "bump:minor": "node scripts/bump-version.js minor",
33
33
  "bump:patch": "node scripts/bump-version.js patch",
34
34
  "test": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js",
35
35
  "test:unit": "npm run build && node --test --test-timeout=5000 dist/tests/unit/*.js",
36
36
  "test:integration": "node dist/scripts/integration-test.js",
37
- "clean": "rm -rf dist"
37
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" "
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/chrome": "^0.0.277",
41
41
  "@types/node": "^24",
42
42
  "esbuild": "^0.27.3",
43
43
  "typescript": "^5.4.5"
44
+ },
45
+ "optionalDependencies": {
46
+ "tabctl-win32-x64": "0.3.0"
44
47
  }
45
48
  }