@triflux/remote 10.48.1 → 10.49.0

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.
package/hub/server.mjs CHANGED
@@ -22,13 +22,7 @@ import {
22
22
  ListToolsRequestSchema,
23
23
  } from "@modelcontextprotocol/sdk/types.js";
24
24
  import { runCollect } from "../cto/collect.mjs";
25
- import { resolveLakeRootDir } from "../cto/lake-root.mjs";
26
- import { runStatus as runCtoStatus } from "../cto/status.mjs";
27
25
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
28
- import {
29
- inspectRegistry,
30
- inspectRegistryStatus,
31
- } from "../scripts/lib/mcp-guard-engine.mjs";
32
26
  import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
33
27
  import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
34
28
  import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
@@ -56,7 +50,6 @@ import {
56
50
  recordWorker,
57
51
  snapshot as traceSnapshot,
58
52
  } from "@triflux/core/hub/lib/trace-recorder.mjs";
59
- import { focusSessionOnMac } from "./mac-focus.mjs";
60
53
  import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
61
54
  import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
62
55
  import { createPipeServer } from "./pipe.mjs";
@@ -79,9 +72,6 @@ import {
79
72
  } from "./team/synapse-registry.mjs";
80
73
  import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
81
74
  import { createTools } from "./tools.mjs";
82
- import { spawnTrayForHub } from "./tray-lifecycle.mjs";
83
- import { getRuntimeStatus } from "./tray-runtime.mjs";
84
- import { buildTrayStatePayload } from "./tray-state.mjs";
85
75
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
86
76
 
87
77
  registerTeamBridge(nativeProxy);
@@ -101,7 +91,6 @@ const LOOPBACK_REMOTE_ADDRESSES = new Set([
101
91
  const ALLOWED_ORIGIN_RE =
102
92
  /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
103
93
  const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
104
- const CANONICAL_PROJECT_ROOT = resolveLakeRootDir(PROJECT_ROOT) || PROJECT_ROOT;
105
94
  const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
106
95
  const CACHE_DIR = join(homedir(), ".claude", "cache");
107
96
  const HUB_DEFAULT_PORT = 27888;
@@ -498,7 +487,6 @@ function isPublicPath(path) {
498
487
  return (
499
488
  PUBLIC_PATHS.has(path) ||
500
489
  path === "/dashboard" ||
501
- path === "/tray.html" ||
502
490
  path === "/api/qos-stats" ||
503
491
  path.startsWith("/public/")
504
492
  );
@@ -861,41 +849,10 @@ function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
861
849
  return currentBroker.publicSnapshot();
862
850
  }
863
851
 
864
- async function getTrayCtoStatus() {
865
- try {
866
- return await runCtoStatus(["--json"], {
867
- rootDir: CANONICAL_PROJECT_ROOT,
868
- stdout: { write() {} },
869
- });
870
- } catch (error) {
871
- return { error: error?.message || String(error) };
872
- }
873
- }
874
-
875
- function getTrayMcpStatus() {
876
- const registryState = inspectRegistry();
877
- if (!registryState.exists || !registryState.valid) {
878
- return {
879
- registry_path: registryState.path,
880
- server_count: 0,
881
- rows: [],
882
- error: registryState.errors?.join("; ") || "MCP registry unavailable",
883
- };
884
- }
885
- const status = inspectRegistryStatus(registryState.registry);
886
- return {
887
- registry_path: registryState.path,
888
- server_count: Object.keys(registryState.registry?.servers || {}).length,
889
- rows: status.rows,
890
- };
891
- }
892
-
893
852
  function resolvePublicFilePath(path) {
894
853
  let relativePath = null;
895
854
  if (path === "/dashboard") {
896
855
  relativePath = "dashboard.html";
897
- } else if (path === "/tray.html") {
898
- relativePath = "tray.html";
899
856
  } else if (path.startsWith("/public/")) {
900
857
  relativePath = path.slice("/public/".length);
901
858
  }
@@ -1441,76 +1398,6 @@ export async function startHub({
1441
1398
  });
1442
1399
  }
1443
1400
 
1444
- if (path === "/api/tray-state" && req.method === "GET") {
1445
- if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1446
- return writeJson(res, 403, { ok: false, error: "Loopback only" });
1447
- }
1448
-
1449
- const qos = getQosStatsPayload();
1450
- const synapseSnapshot = synapseRegistry.snapshot();
1451
- const broker = getBrokerPublicSnapshot(brokerInstance);
1452
- const runtimeStatus = getRuntimeStatus({
1453
- projectRoots: [
1454
- ...new Set([CANONICAL_PROJECT_ROOT, PROJECT_ROOT].filter(Boolean)),
1455
- ],
1456
- });
1457
- const [ctoStatus, mcpStatus, hubStatus] = await Promise.all([
1458
- getTrayCtoStatus(),
1459
- Promise.resolve(getTrayMcpStatus()),
1460
- pipe.executeQuery("status", {
1461
- scope: "hub",
1462
- include_metrics: true,
1463
- }),
1464
- ]);
1465
- const mergedCtoStatus = {
1466
- ...(ctoStatus || {}),
1467
- roles: hubStatus?.data?.roles || ctoStatus?.roles || {},
1468
- };
1469
-
1470
- return writeJson(
1471
- res,
1472
- 200,
1473
- buildTrayStatePayload({
1474
- hub: {
1475
- id: String(sessionId),
1476
- pid: process.pid,
1477
- port,
1478
- url: buildHubUrl(host, port),
1479
- projectRoot: CANONICAL_PROJECT_ROOT,
1480
- },
1481
- qos,
1482
- synapseSnapshot,
1483
- broker,
1484
- ctoStatus: mergedCtoStatus,
1485
- mcpStatus,
1486
- runtimeStatus,
1487
- }),
1488
- );
1489
- }
1490
-
1491
- if (path === "/api/focus-session" && req.method === "POST") {
1492
- if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1493
- return writeJson(res, 403, { ok: false, error: "Loopback only" });
1494
- }
1495
- let body;
1496
- try {
1497
- body = await parseBody(req);
1498
- } catch (_e) {
1499
- return writeJson(res, 400, {
1500
- error: "Invalid JSON or Body too large",
1501
- });
1502
- }
1503
-
1504
- const { sessionId, udsId } = body || {};
1505
- const result = await focusSessionOnMac({
1506
- sessionId,
1507
- udsId,
1508
- pid: body?.pid,
1509
- agentId: body?.agentId ?? body?.agent_id,
1510
- });
1511
- return writeJson(res, 200, { ok: result.ok !== false, focus: result });
1512
- }
1513
-
1514
1401
  // Redacted peer-discovery surface. Returns co-located live peers (same
1515
1402
  // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1516
1403
  if (path === "/synapse/peers" && req.method === "GET") {
@@ -3110,15 +2997,6 @@ if (selfRun) {
3110
2997
  process.exit(0);
3111
2998
  return;
3112
2999
  }
3113
- try {
3114
- const trayPath = fileURLToPath(new URL("./tray.mjs", import.meta.url));
3115
- const tray = spawnTrayForHub({ trayPath });
3116
- if (tray.status === "started") {
3117
- hubLog.info({ pid: tray.pid }, "tray.auto_started");
3118
- }
3119
- } catch (error) {
3120
- hubLog.warn({ err: error }, "tray.auto_start_failed");
3121
- }
3122
3000
  const shutdown = async (signal) => {
3123
3001
  hubLog.info({ signal }, "hub.stopping");
3124
3002
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.48.1",
3
+ "version": "10.49.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -9,7 +9,7 @@
9
9
  "./hub/*": "./hub/*"
10
10
  },
11
11
  "engines": {
12
- "node": ">=18.0.0"
12
+ "node": ">=20"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@triflux/core": "^10.0.0-alpha.1"
@@ -20,7 +20,6 @@
20
20
  "better-sqlite3": "^12.10.0",
21
21
  "pino": "^10.3.1",
22
22
  "pino-pretty": "^13.1.3",
23
- "systray2": "^2.1.4",
24
23
  "zod": "^4.0.0"
25
24
  },
26
25
  "files": [
@@ -2,6 +2,11 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  const VALID_MCP_ROUTES = new Set(["codex", "antigravity", "gemini", "claude"]);
4
4
 
5
+ // 주입 강도. explicit = 스킬명 직접 언급(명시 토큰) → MUST 호출 문구,
6
+ // suggest = 자연어 매칭 → "실행 요청이면 고려하라" 수준의 제안 문구 (ADR-0021).
7
+ const VALID_STRENGTHS = new Set(["explicit", "suggest"]);
8
+ const DEFAULT_STRENGTH = "explicit";
9
+
5
10
  function logRuleError(message, error) {
6
11
  if (error) {
7
12
  console.error(`[triflux-keyword-rules] ${message}: ${error.message}`);
@@ -13,7 +18,11 @@ function logRuleError(message, error) {
13
18
  function normalizePattern(pattern) {
14
19
  if (!pattern || typeof pattern.source !== "string") return null;
15
20
  if (typeof pattern.flags !== "string") return null;
16
- return { source: pattern.source, flags: pattern.flags };
21
+ if (pattern.strength != null && !VALID_STRENGTHS.has(pattern.strength))
22
+ return null;
23
+ const normalized = { source: pattern.source, flags: pattern.flags };
24
+ if (pattern.strength != null) normalized.strength = pattern.strength;
25
+ return normalized;
17
26
  }
18
27
 
19
28
  function normalizeState(state) {
@@ -32,13 +41,43 @@ function normalizeRule(rule) {
32
41
  if (typeof rule.priority !== "number" || !Number.isFinite(rule.priority))
33
42
  return null;
34
43
 
35
- const patterns = rule.patterns.map(normalizePattern).filter(Boolean);
44
+ // strength: 규칙 기본 주입 강도. 패턴별 strength 가 있으면 그 패턴만 덮어쓴다.
45
+ // 값이 틀리면 규칙 자체를 무효로 본다 (platform 과 같은 규칙).
46
+ if (rule.strength != null && !VALID_STRENGTHS.has(rule.strength)) return null;
47
+ const strength = rule.strength ?? DEFAULT_STRENGTH;
48
+
49
+ const patterns = rule.patterns
50
+ .map(normalizePattern)
51
+ .filter(Boolean)
52
+ .map((p) => ({ ...p, strength: p.strength ?? strength }));
36
53
  if (patterns.length === 0) return null;
37
54
 
55
+ // exclude_patterns: 하나라도 맞으면 그 규칙의 suggest 매칭을 버린다.
56
+ // 명시 토큰(explicit) 매칭은 사용자가 스킬을 직접 부른 것이라 제외하지 않는다.
57
+ if (rule.exclude_patterns != null && !Array.isArray(rule.exclude_patterns))
58
+ return null;
59
+ const excludePatterns = Array.isArray(rule.exclude_patterns)
60
+ ? rule.exclude_patterns
61
+ .map(normalizePattern)
62
+ .filter(Boolean)
63
+ .map(({ source, flags }) => ({ source, flags }))
64
+ : [];
65
+
66
+ // skill_candidates: 머신마다 설치 이름이 다른 외부 스킬(gstack 의 `ship` /
67
+ // `gstack-ship`)의 후보 목록. detector 가 실행 시점에 설치된 첫 이름을 고른다.
68
+ // skill 이 없으면 첫 후보를 대표 이름으로 둔다.
69
+ if (rule.skill_candidates != null && !Array.isArray(rule.skill_candidates))
70
+ return null;
71
+ const skillCandidates = Array.isArray(rule.skill_candidates)
72
+ ? rule.skill_candidates
73
+ .filter((name) => typeof name === "string" && name.trim())
74
+ .map((name) => name.trim())
75
+ : [];
76
+
38
77
  const skill =
39
78
  typeof rule.skill === "string" && rule.skill.trim()
40
79
  ? rule.skill.trim()
41
- : null;
80
+ : (skillCandidates[0] ?? null);
42
81
  const action =
43
82
  typeof rule.action === "string" && rule.action.trim()
44
83
  ? rule.action.trim()
@@ -53,6 +92,12 @@ function normalizeRule(rule) {
53
92
  const hint =
54
93
  typeof rule.hint === "string" && rule.hint.trim() ? rule.hint.trim() : null;
55
94
 
95
+ // suggest_when: suggest 주입문에서 "…이면 <skill> 을 고려하라"의 조건 문구.
96
+ const suggestWhen =
97
+ typeof rule.suggest_when === "string" && rule.suggest_when.trim()
98
+ ? rule.suggest_when.trim()
99
+ : null;
100
+
56
101
  const supersedes = Array.isArray(rule.supersedes)
57
102
  ? rule.supersedes
58
103
  .filter((id) => typeof id === "string" && id.trim())
@@ -82,7 +127,11 @@ function normalizeRule(rule) {
82
127
  return {
83
128
  id: rule.id.trim(),
84
129
  patterns,
130
+ exclude_patterns: excludePatterns,
131
+ strength,
132
+ suggest_when: suggestWhen,
85
133
  skill,
134
+ skill_candidates: skillCandidates,
86
135
  action,
87
136
  hint,
88
137
  priority: rule.priority,
@@ -124,6 +173,9 @@ export function compileRules(rules) {
124
173
  compiledPatterns: rule.patterns.map(
125
174
  (p) => new RegExp(p.source, p.flags),
126
175
  ),
176
+ compiledExcludes: (rule.exclude_patterns || []).map(
177
+ (p) => new RegExp(p.source, p.flags),
178
+ ),
127
179
  };
128
180
  } catch (error) {
129
181
  logRuleError(`정규식 컴파일 실패: ${rule.id}`, error);
@@ -153,12 +205,27 @@ export function matchRules(compiledRules, cleanText) {
153
205
  continue;
154
206
  }
155
207
 
156
- const matched = rule.compiledPatterns.some((pattern) => {
208
+ // compiledPatterns[i] 와 patterns[i] 는 같은 순서다 — 패턴별 strength 를 읽는다.
209
+ const ruleStrength = rule.strength ?? DEFAULT_STRENGTH;
210
+ let explicitHit = false;
211
+ let suggestHit = false;
212
+ rule.compiledPatterns.forEach((pattern, index) => {
157
213
  pattern.lastIndex = 0;
158
- return pattern.test(cleanText);
214
+ if (!pattern.test(cleanText)) return;
215
+ const strength = rule.patterns?.[index]?.strength ?? ruleStrength;
216
+ if (strength === "suggest") suggestHit = true;
217
+ else explicitHit = true;
159
218
  });
160
219
 
161
- if (!matched) continue;
220
+ if (!explicitHit && !suggestHit) continue;
221
+
222
+ if (!explicitHit) {
223
+ const excluded = (rule.compiledExcludes || []).some((pattern) => {
224
+ pattern.lastIndex = 0;
225
+ return pattern.test(cleanText);
226
+ });
227
+ if (excluded) continue;
228
+ }
162
229
 
163
230
  matches.push({
164
231
  id: rule.id,
@@ -173,6 +240,9 @@ export function matchRules(compiledRules, cleanText) {
173
240
  repo_scope: rule.repo_scope || [],
174
241
  platform: rule.platform || [],
175
242
  explicit: rule.explicit === true,
243
+ strength: explicitHit ? "explicit" : "suggest",
244
+ suggest_when: rule.suggest_when || null,
245
+ skill_candidates: rule.skill_candidates || [],
176
246
  });
177
247
  }
178
248
 
@@ -197,13 +267,20 @@ export function resolveConflicts(matches) {
197
267
  seen.add(match.id);
198
268
  }
199
269
 
270
+ // strength 우선순위를 충돌 해소에도 적용한다 — suggest 매칭의 supersedes 는
271
+ // 사용자가 직접 부른 explicit 매칭을 지우지 못한다.
272
+ const strengthById = new Map(
273
+ deduped.map((match) => [match.id, match.strength ?? DEFAULT_STRENGTH]),
274
+ );
200
275
  const superseded = new Set();
201
276
  const resolved = [];
202
277
 
203
278
  for (const match of deduped) {
204
279
  if (superseded.has(match.id)) continue;
205
280
  resolved.push(match);
281
+ const fromSuggest = (match.strength ?? DEFAULT_STRENGTH) === "suggest";
206
282
  for (const targetId of match.supersedes || []) {
283
+ if (fromSuggest && strengthById.get(targetId) === "explicit") continue;
207
284
  superseded.add(targetId);
208
285
  }
209
286
  }
package/hub/mac-focus.mjs DELETED
@@ -1,94 +0,0 @@
1
- import { execFile } from "node:child_process";
2
-
3
- export const FOCUS_SCRIPT_TIMEOUT_MS = 5_000;
4
-
5
- const DEFAULT_FOCUS_CANDIDATES = [
6
- "iTerm2",
7
- "Terminal",
8
- "Codex",
9
- "Claude",
10
- "Antigravity",
11
- "Code",
12
- ];
13
-
14
- function normalizeAgentId(value) {
15
- const id = String(value || "")
16
- .trim()
17
- .toLowerCase();
18
- if (id === "agy" || id === "anti") return "antigravity";
19
- return id;
20
- }
21
-
22
- export function getFocusCandidates({ agentId } = {}) {
23
- switch (normalizeAgentId(agentId)) {
24
- case "codex":
25
- return ["Codex", "iTerm2", "Terminal", "Code"];
26
- case "claude":
27
- return ["Claude", "iTerm2", "Terminal"];
28
- case "antigravity":
29
- return ["Antigravity", "iTerm2", "Terminal"];
30
- default:
31
- return [...DEFAULT_FOCUS_CANDIDATES];
32
- }
33
- }
34
-
35
- function appleScriptList(values) {
36
- return `{${values.map((value) => JSON.stringify(String(value))).join(", ")}}`;
37
- }
38
-
39
- export function buildFocusScript({ agentId, appCandidates } = {}) {
40
- const candidates = Array.isArray(appCandidates)
41
- ? appCandidates.map(String).filter(Boolean)
42
- : getFocusCandidates({ agentId });
43
- return `
44
- set appCandidates to ${appleScriptList(candidates)}
45
- tell application "System Events"
46
- set runningNames to name of every process
47
- end tell
48
- repeat with appName in appCandidates
49
- set appText to appName as text
50
- if runningNames contains appText then
51
- tell application appText to activate
52
- return "activated " & appText
53
- end if
54
- end repeat
55
- return "no focus candidate running"
56
- `;
57
- }
58
-
59
- export function focusSessionOnMac(target = {}, maybeUdsId = null) {
60
- const normalizedTarget =
61
- target && typeof target === "object"
62
- ? target
63
- : { sessionId: target, udsId: maybeUdsId };
64
- const script = buildFocusScript({ agentId: normalizedTarget.agentId });
65
- return new Promise((resolve) => {
66
- execFile(
67
- "osascript",
68
- ["-e", script],
69
- { timeout: FOCUS_SCRIPT_TIMEOUT_MS },
70
- (err, stdout = "", stderr = "") => {
71
- const output = String(stdout || stderr || "").trim();
72
- if (err) {
73
- console.error("mac-focus error:", err?.message || err);
74
- resolve({
75
- ok: false,
76
- sessionId: normalizedTarget.sessionId || null,
77
- pid: Number(normalizedTarget.pid) || null,
78
- agentId: normalizedTarget.agentId || null,
79
- error: err?.message || String(err),
80
- output,
81
- });
82
- return;
83
- }
84
- resolve({
85
- ok: true,
86
- sessionId: normalizedTarget.sessionId || null,
87
- pid: Number(normalizedTarget.pid) || null,
88
- agentId: normalizedTarget.agentId || null,
89
- output,
90
- });
91
- },
92
- );
93
- });
94
- }
@@ -1,144 +0,0 @@
1
- import Cocoa
2
- import WebKit
3
-
4
- class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
5
- var statusItem: NSStatusItem!
6
- var popover: NSPopover!
7
- var webVC: WebViewController!
8
- var port: String = "27888"
9
-
10
- func applicationDidFinishLaunching(_ aNotification: Notification) {
11
- if CommandLine.arguments.count > 1 {
12
- port = CommandLine.arguments[1]
13
- }
14
-
15
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
16
- if let button = statusItem.button {
17
- button.title = "T"
18
- button.target = self
19
- button.action = #selector(togglePopover(_:))
20
- }
21
-
22
- webVC = WebViewController()
23
- webVC.port = port
24
- webVC.onFocusComplete = { [weak self] in
25
- self?.popover.performClose(nil)
26
- }
27
-
28
- popover = NSPopover()
29
- popover.contentViewController = webVC
30
- popover.behavior = .transient
31
- popover.animates = false
32
- popover.delegate = self
33
- }
34
-
35
- @objc func togglePopover(_ sender: Any?) {
36
- if popover.isShown {
37
- popover.performClose(sender)
38
- } else {
39
- if let button = statusItem.button {
40
- webVC.reloadForPopover()
41
- popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
42
- }
43
- }
44
- }
45
- }
46
-
47
- class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
48
- var webView: WKWebView!
49
- var port: String = "27888"
50
- let canonicalPort = "27888"
51
- var consecutiveFailures = 0
52
- var retryWorkItem: DispatchWorkItem?
53
- var onFocusComplete: (() -> Void)?
54
-
55
- func logError(_ msg: String) {
56
- let path = "/tmp/mac-tray.log"
57
- if let outputStream = OutputStream(toFileAtPath: path, append: true) {
58
- outputStream.open()
59
- let text = "\(Date()): \(msg)\n"
60
- let bytesWritten = outputStream.write(text, maxLength: text.utf8.count)
61
- outputStream.close()
62
- }
63
- }
64
-
65
- override func loadView() {
66
- let webConfiguration = WKWebViewConfiguration()
67
- webConfiguration.userContentController.add(self, name: "tray")
68
- let initialFrame = NSRect(x: 0, y: 0, width: 460, height: 720)
69
- webView = WKWebView(frame: initialFrame, configuration: webConfiguration)
70
-
71
- // Transparent background for Glassmorphism
72
- webView.setValue(false, forKey: "drawsBackground")
73
-
74
- self.view = NSView(frame: initialFrame)
75
- webView.frame = self.view.bounds
76
- webView.autoresizingMask = [.width, .height]
77
- self.view.addSubview(webView)
78
-
79
- self.preferredContentSize = initialFrame.size
80
- }
81
-
82
- override func viewDidLoad() {
83
- super.viewDidLoad()
84
- webView.navigationDelegate = self
85
- loadTray()
86
- }
87
-
88
- func loadTray() {
89
- retryWorkItem?.cancel()
90
- if let url = URL(string: "http://127.0.0.1:\(port)/tray.html?t=\(Date().timeIntervalSince1970)") {
91
- let request = URLRequest(url: url)
92
- webView.load(request)
93
- } else {
94
- logError("Invalid URL")
95
- }
96
- }
97
-
98
- func reloadForPopover() {
99
- if isViewLoaded {
100
- loadTray()
101
- }
102
- }
103
-
104
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
105
- logError("Failed to load: \(error.localizedDescription)")
106
- consecutiveFailures += 1
107
- if consecutiveFailures >= 3 && port != canonicalPort {
108
- port = canonicalPort
109
- consecutiveFailures = 0
110
- logError("Switching to canonical port \(canonicalPort) after repeated failures")
111
- loadTray()
112
- return
113
- }
114
- let retry = DispatchWorkItem { [weak self] in
115
- self?.loadTray()
116
- }
117
- retryWorkItem = retry
118
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: retry)
119
- }
120
-
121
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
122
- retryWorkItem?.cancel()
123
- retryWorkItem = nil
124
- consecutiveFailures = 0
125
- logError("Successfully loaded URL")
126
- }
127
-
128
- func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
129
- guard message.name == "tray" else { return }
130
- if let body = message.body as? [String: Any],
131
- let type = body["type"] as? String,
132
- type == "focus-complete" {
133
- DispatchQueue.main.async { [weak self] in
134
- self?.onFocusComplete?()
135
- }
136
- }
137
- }
138
- }
139
-
140
- let app = NSApplication.shared
141
- let delegate = AppDelegate()
142
- app.delegate = delegate
143
- app.setActivationPolicy(.accessory)
144
- app.run()
Binary file
Binary file