@m13v/s4l 1.7.2-rc.9 → 1.7.3
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/mcp/dist/index.js +61 -9
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +312 -29
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +278 -62
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/feedback_digest.py +1 -1
- package/scripts/merge_review_queue.py +87 -21
- package/scripts/pick_project.py +12 -3
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/schedule_state.py +53 -0
- package/scripts/twitter_post_plan.py +67 -0
- package/skill/run-draft-and-publish.sh +4 -3
package/mcp/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import os from "node:os";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
23
|
-
import { applySetup, resolveProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
23
|
+
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
25
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, retryProvisionIfStalled, } from "./runtime.js";
|
|
26
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
@@ -870,6 +870,18 @@ async function ensureTwitterBrowserForPost() {
|
|
|
870
870
|
},
|
|
871
871
|
});
|
|
872
872
|
}
|
|
873
|
+
// A terminal stamp written by merge_review_queue.py's backend sync for a row the
|
|
874
|
+
// freshness gate merely EXPIRED (not posted, not skipped) records "nobody decided
|
|
875
|
+
// this in time" — an explicit human approval outranks it. The poster's own
|
|
876
|
+
// at-post-time tweet_unavailable check remains the real gate on whether the
|
|
877
|
+
// thread still exists. Without this override, a card approved while (or just
|
|
878
|
+
// before) the sync stamped it is refused as already-decided and the approval
|
|
879
|
+
// silently no-ops (2 of 3 approvals lost on 2026-07-10).
|
|
880
|
+
function expiredStampOverridable(c) {
|
|
881
|
+
return (c.terminal === true &&
|
|
882
|
+
c.posted !== true &&
|
|
883
|
+
c.discard_reason === "backend_status_expired");
|
|
884
|
+
}
|
|
873
885
|
async function postApproved(batchId, plan) {
|
|
874
886
|
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
875
887
|
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
@@ -922,7 +934,18 @@ async function postApproved(batchId, plan) {
|
|
|
922
934
|
// drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
|
|
923
935
|
// never re-posts a done one. This is what lets the startup backlog-drain and the
|
|
924
936
|
// per-card menu-bar calls share one code path safely.
|
|
925
|
-
|
|
937
|
+
// An approved card whose only blocker is an overridable backend-expiry stamp is
|
|
938
|
+
// included: approval outranks the freshness gate (see expiredStampOverridable),
|
|
939
|
+
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
940
|
+
const approved = (plan.candidates || []).filter((c) => c.approved === true &&
|
|
941
|
+
c.posted !== true &&
|
|
942
|
+
(c.terminal !== true || expiredStampOverridable(c)));
|
|
943
|
+
for (const c of approved) {
|
|
944
|
+
if (c.terminal === true) {
|
|
945
|
+
c.terminal = false;
|
|
946
|
+
delete c.discard_reason;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
926
949
|
if (approved.length === 0)
|
|
927
950
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
928
951
|
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
@@ -1000,7 +1023,12 @@ async function postApproved(batchId, plan) {
|
|
|
1000
1023
|
};
|
|
1001
1024
|
}
|
|
1002
1025
|
return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
|
|
1003
|
-
|
|
1026
|
+
// Scale with batch size: a mass-approval drain runs ~15-20s per card,
|
|
1027
|
+
// so a fixed 15min ceiling SIGTERMed any batch over ~50 cards mid-post
|
|
1028
|
+
// (Karol 2026-07-09: 0/131 posted, exit=-1). 60s/card headroom covers
|
|
1029
|
+
// slow candidates; the 2h cap bounds a hung poster (the browser-lock
|
|
1030
|
+
// expiry and per-reply subprocess timeouts still fire underneath).
|
|
1031
|
+
timeoutMs: Math.min(7_200_000, Math.max(900_000, approved.length * 60_000)),
|
|
1004
1032
|
env: ({
|
|
1005
1033
|
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1006
1034
|
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
@@ -2316,9 +2344,17 @@ tool("post_drafts", {
|
|
|
2316
2344
|
});
|
|
2317
2345
|
// Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
|
|
2318
2346
|
// never re-post a candidate the other surface already posted OR ruled out.
|
|
2347
|
+
// Exception: an overridable backend-expiry stamp yields to this explicit
|
|
2348
|
+
// approval (see expiredStampOverridable) — clear it and post.
|
|
2319
2349
|
const alreadyDone = [];
|
|
2320
2350
|
for (const n of Array.from(approve)) {
|
|
2321
|
-
|
|
2351
|
+
const c = candidates[n - 1];
|
|
2352
|
+
if (c && expiredStampOverridable(c)) {
|
|
2353
|
+
c.terminal = false;
|
|
2354
|
+
delete c.discard_reason;
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
if (c?.posted === true || c?.terminal === true) {
|
|
2322
2358
|
approve.delete(n);
|
|
2323
2359
|
alreadyDone.push(n);
|
|
2324
2360
|
}
|
|
@@ -2387,19 +2423,33 @@ tool("get_stats", {
|
|
|
2387
2423
|
title: "Get X/Twitter stats",
|
|
2388
2424
|
description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
|
|
2389
2425
|
"Wraps project_stats_json.py. Use to show the user how their posts are performing. " +
|
|
2426
|
+
"With no `project` it reports EVERY configured lane, including the personal-brand " +
|
|
2427
|
+
"persona (which usually carries most of the volume) — prefer that default. " +
|
|
2390
2428
|
"After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
|
|
2391
2429
|
inputSchema: {
|
|
2392
2430
|
days: z.number().int().min(1).max(90).default(7),
|
|
2393
2431
|
project: z
|
|
2394
2432
|
.string()
|
|
2395
2433
|
.optional()
|
|
2396
|
-
.describe("
|
|
2434
|
+
.describe("Scope to one configured project (the persona lane's name works too). " +
|
|
2435
|
+
"Omit to report all lanes — products AND the personal-brand persona."),
|
|
2397
2436
|
},
|
|
2398
2437
|
}, async ({ days, project }) => {
|
|
2399
|
-
|
|
2400
|
-
|
|
2438
|
+
// Explicit project: validate it (projectStatus is persona-aware, so the
|
|
2439
|
+
// persona lane resolves). No project: report EVERY lane rather than
|
|
2440
|
+
// resolving to a single product — the old single-project resolution made
|
|
2441
|
+
// the persona lane (often 90% of activity) invisible in stats.
|
|
2442
|
+
let proj;
|
|
2443
|
+
if (project) {
|
|
2444
|
+
const r = resolveProject(project);
|
|
2445
|
+
if (!r.ok)
|
|
2446
|
+
return textContent(r.message);
|
|
2447
|
+
proj = r.project;
|
|
2448
|
+
}
|
|
2449
|
+
else if (!hasReadyProject() && !personaReady()) {
|
|
2450
|
+
const r = resolveProject();
|
|
2401
2451
|
return textContent(r.message);
|
|
2402
|
-
|
|
2452
|
+
}
|
|
2403
2453
|
const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
|
|
2404
2454
|
if (proj)
|
|
2405
2455
|
args.push("--project", proj);
|
|
@@ -4963,7 +5013,9 @@ async function drainApprovedBacklog() {
|
|
|
4963
5013
|
try {
|
|
4964
5014
|
const plan = readPlan(REVIEW_QUEUE_ID);
|
|
4965
5015
|
const cands = plan?.candidates || [];
|
|
4966
|
-
const backlog = cands.filter((c) => c.approved === true &&
|
|
5016
|
+
const backlog = cands.filter((c) => c.approved === true &&
|
|
5017
|
+
c.posted !== true &&
|
|
5018
|
+
(c.terminal !== true || expiredStampOverridable(c)));
|
|
4967
5019
|
if (!backlog.length)
|
|
4968
5020
|
return;
|
|
4969
5021
|
console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
|
package/mcp/dist/screencast.js
CHANGED
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { execFile } from "node:child_process";
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
|
+
import { logLine } from "./telemetry.js";
|
|
22
|
+
// One structured relay line (context "browser-foreground", the same lane the
|
|
23
|
+
// menubar's NSWorkspace observer emits on) every time THIS module raises the
|
|
24
|
+
// managed Chrome. The observer records THAT the window came to the front;
|
|
25
|
+
// these lines record WHY (screencast attach vs explicit front action), so the
|
|
26
|
+
// two join on timestamp in Cloud Logging.
|
|
27
|
+
function logBringToFront(source, extra) {
|
|
28
|
+
try {
|
|
29
|
+
logLine("stdout", JSON.stringify({ ev: "browser_bring_to_front", source, ...extra }), "browser-foreground");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* telemetry must never break the screencast */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
// Untyped indirection: Node ships a global WebSocket at runtime (>=21) but
|
|
22
36
|
// @types/node doesn't always declare it as a value, and MessageEvent isn't typed
|
|
23
37
|
// without the DOM lib. Reach for it dynamically and keep the event handlers `any`.
|
|
@@ -123,6 +137,14 @@ class Screencast {
|
|
|
123
137
|
this.port = chosenPort;
|
|
124
138
|
this.targetTitle = target.title || "";
|
|
125
139
|
this.targetUrl = target.url || "";
|
|
140
|
+
// connect() just sent Page.bringToFront (Chrome won't stream frames for a
|
|
141
|
+
// background tab), which raises the harness window — record each raise.
|
|
142
|
+
// Reconnects happen silently on every frame poll after the attached tab
|
|
143
|
+
// dies, so this line is the ONLY attribution for reconnect-storm raises.
|
|
144
|
+
logBringToFront("screencast_attach", {
|
|
145
|
+
port: chosenPort,
|
|
146
|
+
url: (target.url || "").slice(0, 200),
|
|
147
|
+
});
|
|
126
148
|
return { ok: true };
|
|
127
149
|
}
|
|
128
150
|
catch (e) {
|
|
@@ -333,5 +355,10 @@ export async function bringBrowserToFront(port) {
|
|
|
333
355
|
if (process.platform === "darwin") {
|
|
334
356
|
await raiseMacWindow(chosenPort);
|
|
335
357
|
}
|
|
358
|
+
logBringToFront("front_action", {
|
|
359
|
+
port: chosenPort,
|
|
360
|
+
url: (target.url || "").slice(0, 200),
|
|
361
|
+
raised_os_window: process.platform === "darwin",
|
|
362
|
+
});
|
|
336
363
|
return { ok: true, port: chosenPort };
|
|
337
364
|
}
|
package/mcp/dist/setup.js
CHANGED
|
@@ -486,9 +486,15 @@ export function missingForProject(name, fields = REQUIRED_FIELDS) {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
export function projectStatus(name) {
|
|
489
|
-
|
|
489
|
+
// The persona lane (persona:true) has no website/icp by design — validate it
|
|
490
|
+
// against PERSONA_REQUIRED_FIELDS like every other status surface, or an
|
|
491
|
+
// explicit request for it (e.g. get_stats project:'PersonalBrand') gets the
|
|
492
|
+
// misleading "still needs: website, icp" refusal.
|
|
493
|
+
const persona = findPersonaProject()?.name === name;
|
|
494
|
+
const required = persona ? PERSONA_REQUIRED_FIELDS : REQUIRED_FIELDS;
|
|
495
|
+
const missing = missingForProject(name, required);
|
|
490
496
|
if (missing === null) {
|
|
491
|
-
return { name, in_config: false, ready: false, missing_required: [...
|
|
497
|
+
return { name, in_config: false, ready: false, missing_required: [...required] };
|
|
492
498
|
}
|
|
493
499
|
return { name, in_config: true, ready: missing.length === 0, missing_required: missing };
|
|
494
500
|
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.3",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Ground-truth "the harness browser just went foreground" telemetry.
|
|
2
|
+
|
|
3
|
+
Customers reported the managed Chrome popping over their work (1.7.x made the
|
|
4
|
+
launchd kicker keep a harness Chrome alive all day, the screencast reconnect
|
|
5
|
+
sends Page.bringToFront, and single-display Macs clamp the off-screen
|
|
6
|
+
--window-position back on-screen). None of those moments were recorded
|
|
7
|
+
anywhere: the causing sites had no logs and nothing observed the OS z-order.
|
|
8
|
+
|
|
9
|
+
This module is the cause-agnostic observer. It subscribes to NSWorkspace
|
|
10
|
+
activate/launch notifications and, whenever the app coming to the front is a
|
|
11
|
+
Chrome/Chromium whose command line carries a MANAGED profile
|
|
12
|
+
(~/.claude/browser-profiles/browser-harness*), emits one structured JSON line
|
|
13
|
+
via s4l_log_relay.emit(..., context="browser-foreground"). The relay POSTs it
|
|
14
|
+
to /api/v1/installations/logs under the install's X-Installation identity, so
|
|
15
|
+
in Cloud Logging (project s4l-app-prod) the events are:
|
|
16
|
+
|
|
17
|
+
jsonPayload.context="browser-foreground"
|
|
18
|
+
AND jsonPayload.install_id="<uuid>"
|
|
19
|
+
|
|
20
|
+
Payload fields: cause ("activated" fires on every raise, "launched" only on a
|
|
21
|
+
fresh Chrome process = the launch-activation steal), pid, profile + CDP port +
|
|
22
|
+
--window-position (parsed from the process command line; window-position
|
|
23
|
+
80,80 = setup_twitter_auth's on-screen login, 3042,-1032 = the pipeline
|
|
24
|
+
default), interrupted_app (the frontmost app the user lost), and
|
|
25
|
+
suppressed_since_last (burst dedupe counter, so a bringToFront storm is
|
|
26
|
+
countable without flooding the relay).
|
|
27
|
+
|
|
28
|
+
Design constraints:
|
|
29
|
+
- The notification handler must never block the main run loop: it only
|
|
30
|
+
enqueues; a daemon worker does the `ps` classification + emit.
|
|
31
|
+
- Only harness-Chrome events are emitted. The user's own Chrome, and every
|
|
32
|
+
other app switch, produce zero relay traffic (we keep the last non-harness
|
|
33
|
+
app name in memory as interrupted_app context, nothing more).
|
|
34
|
+
- Strictly best-effort: install() returning False just means no telemetry.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import queue
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
|
|
45
|
+
import s4l_log_relay
|
|
46
|
+
|
|
47
|
+
# A managed harness Chrome is one launched on a profile under this marker
|
|
48
|
+
# (browser-harness = twitter 9555, browser-harness-linkedin = 9556, ...).
|
|
49
|
+
_PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "browser-harness")
|
|
50
|
+
|
|
51
|
+
# Within this window, repeats of the same (cause, pid) are counted, not emitted.
|
|
52
|
+
# A screencast-reconnect storm raises Chrome every few seconds; one line per
|
|
53
|
+
# 30s with suppressed_since_last preserves the frequency without the flood.
|
|
54
|
+
_DEDUPE_SECONDS = 30.0
|
|
55
|
+
|
|
56
|
+
_events = queue.Queue()
|
|
57
|
+
_started = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmdline(pid):
|
|
61
|
+
try:
|
|
62
|
+
out = subprocess.run(
|
|
63
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
64
|
+
capture_output=True, text=True, timeout=3,
|
|
65
|
+
)
|
|
66
|
+
return (out.stdout or "").strip()
|
|
67
|
+
except Exception:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Worker(threading.Thread):
|
|
72
|
+
def __init__(self):
|
|
73
|
+
super().__init__(daemon=True, name="s4l-browser-foreground")
|
|
74
|
+
self._pid_cache = {} # pid -> (is_harness, details dict)
|
|
75
|
+
self._prev_app = None # last non-harness frontmost app name
|
|
76
|
+
self._last_key = None # (cause, pid) of last emitted event
|
|
77
|
+
self._last_emit_at = 0.0
|
|
78
|
+
self._suppressed = 0
|
|
79
|
+
|
|
80
|
+
def _classify(self, pid):
|
|
81
|
+
cached = self._pid_cache.get(pid)
|
|
82
|
+
if cached is not None:
|
|
83
|
+
return cached
|
|
84
|
+
cmd = _cmdline(pid)
|
|
85
|
+
is_harness = _PROFILE_MARKER in cmd and "--remote-debugging-port=" in cmd
|
|
86
|
+
details = {}
|
|
87
|
+
if is_harness:
|
|
88
|
+
m = re.search(r"--user-data-dir=(\S+)", cmd)
|
|
89
|
+
details["profile"] = os.path.basename(m.group(1).rstrip("/")) if m else ""
|
|
90
|
+
m = re.search(r"--remote-debugging-port=(\d+)", cmd)
|
|
91
|
+
details["port"] = int(m.group(1)) if m else None
|
|
92
|
+
m = re.search(r"--window-position=(\S+)", cmd)
|
|
93
|
+
details["window_position"] = m.group(1) if m else None
|
|
94
|
+
result = (is_harness, details)
|
|
95
|
+
# pids recycle rarely; a tiny bounded cache is enough and self-clears.
|
|
96
|
+
if len(self._pid_cache) > 64:
|
|
97
|
+
self._pid_cache.clear()
|
|
98
|
+
self._pid_cache[pid] = result
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _handle(self, cause, pid, name, low):
|
|
102
|
+
if "chrome" not in low and "chromium" not in low:
|
|
103
|
+
if cause == "activated" and name:
|
|
104
|
+
self._prev_app = name
|
|
105
|
+
return
|
|
106
|
+
is_harness, details = self._classify(pid)
|
|
107
|
+
if not is_harness:
|
|
108
|
+
# The user's own Chrome counts as their workspace too.
|
|
109
|
+
if cause == "activated" and name:
|
|
110
|
+
self._prev_app = name
|
|
111
|
+
return
|
|
112
|
+
now = time.time()
|
|
113
|
+
key = (cause, pid)
|
|
114
|
+
if key == self._last_key and now - self._last_emit_at < _DEDUPE_SECONDS:
|
|
115
|
+
self._suppressed += 1
|
|
116
|
+
return
|
|
117
|
+
payload = {
|
|
118
|
+
"ev": "harness_browser_foregrounded",
|
|
119
|
+
"cause": cause, # "activated" | "launched"
|
|
120
|
+
"app": name,
|
|
121
|
+
"pid": pid,
|
|
122
|
+
"interrupted_app": self._prev_app,
|
|
123
|
+
"suppressed_since_last": self._suppressed,
|
|
124
|
+
}
|
|
125
|
+
payload.update(details)
|
|
126
|
+
self._last_key = key
|
|
127
|
+
self._last_emit_at = now
|
|
128
|
+
self._suppressed = 0
|
|
129
|
+
s4l_log_relay.emit(
|
|
130
|
+
"[browser-foreground] " + json.dumps(payload, ensure_ascii=False),
|
|
131
|
+
context="browser-foreground",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def run(self):
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
cause, pid, name, low = _events.get()
|
|
138
|
+
self._handle(cause, pid, name, low)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Never die: a bad event must not end foreground telemetry.
|
|
141
|
+
time.sleep(0.5)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def install():
|
|
145
|
+
"""Subscribe to NSWorkspace activate/launch notifications and start the
|
|
146
|
+
classifier worker. Call once at menubar boot (the AppKit run loop rumps
|
|
147
|
+
starts delivers the notifications). Returns True on success; never raises."""
|
|
148
|
+
global _started
|
|
149
|
+
if _started:
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
from AppKit import NSWorkspace
|
|
153
|
+
|
|
154
|
+
nc = NSWorkspace.sharedWorkspace().notificationCenter()
|
|
155
|
+
|
|
156
|
+
def _make(cause):
|
|
157
|
+
def _handler(note):
|
|
158
|
+
try:
|
|
159
|
+
app = note.userInfo().objectForKey_("NSWorkspaceApplicationKey")
|
|
160
|
+
if app is None:
|
|
161
|
+
return
|
|
162
|
+
name = str(app.localizedName() or "")
|
|
163
|
+
bid = str(app.bundleIdentifier() or "")
|
|
164
|
+
pid = int(app.processIdentifier())
|
|
165
|
+
_events.put((cause, pid, name, (name + " " + bid).lower()))
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return _handler
|
|
169
|
+
|
|
170
|
+
# Keep the block handlers referenced for the process lifetime: PyObjC
|
|
171
|
+
# does retain the blocks it wraps, but the observer tokens returned
|
|
172
|
+
# here are our only handle if we ever need to removeObserver_.
|
|
173
|
+
tokens = [
|
|
174
|
+
nc.addObserverForName_object_queue_usingBlock_(
|
|
175
|
+
"NSWorkspaceDidActivateApplicationNotification", None, None,
|
|
176
|
+
_make("activated"),
|
|
177
|
+
),
|
|
178
|
+
nc.addObserverForName_object_queue_usingBlock_(
|
|
179
|
+
"NSWorkspaceDidLaunchApplicationNotification", None, None,
|
|
180
|
+
_make("launched"),
|
|
181
|
+
),
|
|
182
|
+
]
|
|
183
|
+
install._tokens = tokens # noqa: SLF001 (lifetime anchor)
|
|
184
|
+
_Worker().start()
|
|
185
|
+
_started = True
|
|
186
|
+
return True
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|