synabun 1.3.0 → 2.0.0-beta
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/CHANGELOG.md +197 -662
- package/LICENSE-COMMERCIAL.md +33 -0
- package/NOTICE +17 -0
- package/README.md +175 -853
- package/THIRD-PARTY-LICENSES.md +131 -0
- package/benchmarks/browser-live-results.json +79 -0
- package/benchmarks/browser-v2-results.json +258 -0
- package/benchmarks/fixtures/browser/README.md +57 -0
- package/benchmarks/fixtures/browser/page-state.json +132 -0
- package/benchmarks/fixtures/browser/social.json +92 -0
- package/benchmarks/fixtures/browser/target.json +319 -0
- package/benchmarks/fixtures/desktop/README.md +58 -0
- package/benchmarks/fixtures/desktop/abstention.json +59 -0
- package/benchmarks/fixtures/desktop/adversarial.json +22 -0
- package/benchmarks/fixtures/desktop/forbidden.json +93 -0
- package/benchmarks/fixtures/desktop/generate.mjs +1023 -0
- package/benchmarks/fixtures/desktop/target.json +136 -0
- package/bin/synabun.js +30 -0
- package/docs/browser-v2.md +109 -0
- package/docs/hook-integrity.md +89 -0
- package/docs/judgments.md +182 -0
- package/docs/style-guide.md +246 -0
- package/docs/whatsapp.md +325 -0
- package/hooks/claude-code/post-plan.mjs +613 -391
- package/hooks/claude-code/post-remember.mjs +246 -122
- package/hooks/claude-code/pre-compact.mjs +171 -110
- package/hooks/claude-code/pre-task.mjs +133 -0
- package/hooks/claude-code/pre-websearch.mjs +15 -4
- package/hooks/claude-code/prompt-origin.mjs +94 -0
- package/hooks/claude-code/prompt-submit.mjs +1244 -861
- package/hooks/claude-code/session-start.mjs +114 -156
- package/hooks/claude-code/shared.mjs +1228 -368
- package/hooks/claude-code/state.mjs +167 -0
- package/hooks/claude-code/stop.mjs +515 -140
- package/hooks/claude-code/subagent-start.mjs +154 -0
- package/hooks/claude-code/subagent-stop.mjs +102 -0
- package/hooks/claude-code/task-gate.mjs +150 -0
- package/lib/claude-hooks.js +84 -0
- package/lib/claude-usage-audit.js +141 -0
- package/lib/client-config-repair.js +144 -0
- package/lib/data-home-migration.js +498 -0
- package/lib/first-launch-protection.js +240 -0
- package/lib/paths.d.ts +21 -0
- package/lib/paths.js +103 -0
- package/mcp-server/dist/config.d.ts +22 -0
- package/mcp-server/dist/config.d.ts.map +1 -0
- package/mcp-server/dist/config.js +109 -0
- package/mcp-server/dist/config.js.map +1 -0
- package/mcp-server/dist/http.d.ts +30 -0
- package/mcp-server/dist/http.d.ts.map +1 -0
- package/mcp-server/dist/http.js +276 -0
- package/mcp-server/dist/http.js.map +1 -0
- package/mcp-server/dist/index.d.ts +48 -0
- package/mcp-server/dist/index.d.ts.map +1 -0
- package/mcp-server/dist/index.js +538 -0
- package/mcp-server/dist/index.js.map +1 -0
- package/mcp-server/dist/preload-http.d.ts +3 -0
- package/mcp-server/dist/preload-http.d.ts.map +1 -0
- package/mcp-server/dist/preload-http.js +28 -0
- package/mcp-server/dist/preload-http.js.map +1 -0
- package/mcp-server/dist/preload.d.ts +3 -0
- package/mcp-server/dist/preload.d.ts.map +1 -0
- package/mcp-server/dist/preload.js +32 -0
- package/mcp-server/dist/preload.js.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
- package/mcp-server/dist/scripts/setup-embeddings.d.ts +3 -0
- package/mcp-server/dist/scripts/setup-embeddings.d.ts.map +1 -0
- package/mcp-server/dist/scripts/setup-embeddings.js +15 -0
- package/mcp-server/dist/scripts/setup-embeddings.js.map +1 -0
- package/mcp-server/dist/services/assistant-judgments.d.ts +79 -0
- package/mcp-server/dist/services/assistant-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/assistant-judgments.js +128 -0
- package/mcp-server/dist/services/assistant-judgments.js.map +1 -0
- package/mcp-server/dist/services/browser-assist-gate.d.ts +314 -0
- package/mcp-server/dist/services/browser-assist-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-assist-gate.js +344 -0
- package/mcp-server/dist/services/browser-assist-gate.js.map +1 -0
- package/mcp-server/dist/services/browser-assist.d.ts +104 -0
- package/mcp-server/dist/services/browser-assist.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-assist.js +282 -0
- package/mcp-server/dist/services/browser-assist.js.map +1 -0
- package/mcp-server/dist/services/browser-bench.d.ts +772 -0
- package/mcp-server/dist/services/browser-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-bench.js +424 -0
- package/mcp-server/dist/services/browser-bench.js.map +1 -0
- package/mcp-server/dist/services/browser-judgments.d.ts +196 -0
- package/mcp-server/dist/services/browser-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-judgments.js +363 -0
- package/mcp-server/dist/services/browser-judgments.js.map +1 -0
- package/mcp-server/dist/services/browser-risk.d.ts +166 -0
- package/mcp-server/dist/services/browser-risk.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-risk.js +458 -0
- package/mcp-server/dist/services/browser-risk.js.map +1 -0
- package/mcp-server/dist/services/categories.d.ts +61 -0
- package/mcp-server/dist/services/categories.d.ts.map +1 -0
- package/mcp-server/dist/services/categories.js +377 -0
- package/mcp-server/dist/services/categories.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +49 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.js +346 -0
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -0
- package/mcp-server/dist/services/desktop-assist-gate.d.ts +198 -0
- package/mcp-server/dist/services/desktop-assist-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-assist-gate.js +202 -0
- package/mcp-server/dist/services/desktop-assist-gate.js.map +1 -0
- package/mcp-server/dist/services/desktop-assist.d.ts +104 -0
- package/mcp-server/dist/services/desktop-assist.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-assist.js +266 -0
- package/mcp-server/dist/services/desktop-assist.js.map +1 -0
- package/mcp-server/dist/services/desktop-bench.d.ts +553 -0
- package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-bench.js +311 -0
- package/mcp-server/dist/services/desktop-bench.js.map +1 -0
- package/mcp-server/dist/services/desktop-client.d.ts +23 -0
- package/mcp-server/dist/services/desktop-client.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-client.js +79 -0
- package/mcp-server/dist/services/desktop-client.js.map +1 -0
- package/mcp-server/dist/services/desktop-judgments.d.ts +125 -0
- package/mcp-server/dist/services/desktop-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-judgments.js +177 -0
- package/mcp-server/dist/services/desktop-judgments.js.map +1 -0
- package/mcp-server/dist/services/desktop-risk.d.ts +283 -0
- package/mcp-server/dist/services/desktop-risk.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-risk.js +605 -0
- package/mcp-server/dist/services/desktop-risk.js.map +1 -0
- package/mcp-server/dist/services/discord.d.ts +112 -0
- package/mcp-server/dist/services/discord.d.ts.map +1 -0
- package/mcp-server/dist/services/discord.js +477 -0
- package/mcp-server/dist/services/discord.js.map +1 -0
- package/mcp-server/dist/services/edit-stale.d.ts +81 -0
- package/mcp-server/dist/services/edit-stale.d.ts.map +1 -0
- package/mcp-server/dist/services/edit-stale.js +358 -0
- package/mcp-server/dist/services/edit-stale.js.map +1 -0
- package/mcp-server/dist/services/embedding-service.d.ts +16 -0
- package/mcp-server/dist/services/embedding-service.d.ts.map +1 -0
- package/mcp-server/dist/services/embedding-service.js +126 -0
- package/mcp-server/dist/services/embedding-service.js.map +1 -0
- package/mcp-server/dist/services/embedding-worker.d.ts +2 -0
- package/mcp-server/dist/services/embedding-worker.d.ts.map +1 -0
- package/mcp-server/dist/services/embedding-worker.js +52 -0
- package/mcp-server/dist/services/embedding-worker.js.map +1 -0
- package/mcp-server/dist/services/fb-regions.d.ts +71 -0
- package/mcp-server/dist/services/fb-regions.d.ts.map +1 -0
- package/mcp-server/dist/services/fb-regions.js +211 -0
- package/mcp-server/dist/services/fb-regions.js.map +1 -0
- package/mcp-server/dist/services/file-checksums.d.ts +22 -0
- package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
- package/mcp-server/dist/services/file-checksums.js +55 -0
- package/mcp-server/dist/services/file-checksums.js.map +1 -0
- package/mcp-server/dist/services/file-excerpt.d.ts +24 -0
- package/mcp-server/dist/services/file-excerpt.d.ts.map +1 -0
- package/mcp-server/dist/services/file-excerpt.js +65 -0
- package/mcp-server/dist/services/file-excerpt.js.map +1 -0
- package/mcp-server/dist/services/identity.d.ts +124 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -0
- package/mcp-server/dist/services/identity.js +241 -0
- package/mcp-server/dist/services/identity.js.map +1 -0
- package/mcp-server/dist/services/local-embeddings.d.ts +2 -0
- package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
- package/mcp-server/dist/services/local-embeddings.js +3 -0
- package/mcp-server/dist/services/local-embeddings.js.map +1 -0
- package/mcp-server/dist/services/memory-client.d.ts +4 -0
- package/mcp-server/dist/services/memory-client.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-client.js +27 -0
- package/mcp-server/dist/services/memory-client.js.map +1 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts +135 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-judge-backfill.js +510 -0
- package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -0
- package/mcp-server/dist/services/memory-judgments.d.ts +362 -0
- package/mcp-server/dist/services/memory-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-judgments.js +674 -0
- package/mcp-server/dist/services/memory-judgments.js.map +1 -0
- package/mcp-server/dist/services/memory-maintenance.d.ts +108 -0
- package/mcp-server/dist/services/memory-maintenance.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-maintenance.js +403 -0
- package/mcp-server/dist/services/memory-maintenance.js.map +1 -0
- package/mcp-server/dist/services/memory-retrieval.d.ts +66 -0
- package/mcp-server/dist/services/memory-retrieval.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-retrieval.js +412 -0
- package/mcp-server/dist/services/memory-retrieval.js.map +1 -0
- package/mcp-server/dist/services/memory-schema.d.ts +16 -0
- package/mcp-server/dist/services/memory-schema.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-schema.js +282 -0
- package/mcp-server/dist/services/memory-schema.js.map +1 -0
- package/mcp-server/dist/services/memory-sessions.d.ts +24 -0
- package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-sessions.js +110 -0
- package/mcp-server/dist/services/memory-sessions.js.map +1 -0
- package/mcp-server/dist/services/memory-triage.d.ts +60 -0
- package/mcp-server/dist/services/memory-triage.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-triage.js +189 -0
- package/mcp-server/dist/services/memory-triage.js.map +1 -0
- package/mcp-server/dist/services/memory-verification.d.ts +24 -0
- package/mcp-server/dist/services/memory-verification.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-verification.js +92 -0
- package/mcp-server/dist/services/memory-verification.js.map +1 -0
- package/mcp-server/dist/services/memory-writer.d.ts +26 -0
- package/mcp-server/dist/services/memory-writer.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-writer.js +126 -0
- package/mcp-server/dist/services/memory-writer.js.map +1 -0
- package/mcp-server/dist/services/neural-interface.d.ts +439 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
- package/mcp-server/dist/services/neural-interface.js +1135 -0
- package/mcp-server/dist/services/neural-interface.js.map +1 -0
- package/mcp-server/dist/services/profiles.d.ts +120 -0
- package/mcp-server/dist/services/profiles.d.ts.map +1 -0
- package/mcp-server/dist/services/profiles.js +387 -0
- package/mcp-server/dist/services/profiles.js.map +1 -0
- package/mcp-server/dist/services/secret-gate.d.ts +29 -0
- package/mcp-server/dist/services/secret-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/secret-gate.js +43 -0
- package/mcp-server/dist/services/secret-gate.js.map +1 -0
- package/mcp-server/dist/services/server-instructions.d.ts +10 -0
- package/mcp-server/dist/services/server-instructions.d.ts.map +1 -0
- package/mcp-server/dist/services/server-instructions.js +101 -0
- package/mcp-server/dist/services/server-instructions.js.map +1 -0
- package/mcp-server/dist/services/sqlite.d.ts +235 -0
- package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
- package/mcp-server/dist/services/sqlite.js +1185 -0
- package/mcp-server/dist/services/sqlite.js.map +1 -0
- package/mcp-server/dist/services/typesafe-bench.d.ts +99 -0
- package/mcp-server/dist/services/typesafe-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe-bench.js +218 -0
- package/mcp-server/dist/services/typesafe-bench.js.map +1 -0
- package/mcp-server/dist/services/typesafe-config.d.ts +342 -0
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe-config.js +746 -0
- package/mcp-server/dist/services/typesafe-config.js.map +1 -0
- package/mcp-server/dist/services/typesafe.d.ts +213 -0
- package/mcp-server/dist/services/typesafe.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe.js +330 -0
- package/mcp-server/dist/services/typesafe.js.map +1 -0
- package/mcp-server/dist/services/vector-cache.d.ts +68 -0
- package/mcp-server/dist/services/vector-cache.d.ts.map +1 -0
- package/mcp-server/dist/services/vector-cache.js +271 -0
- package/mcp-server/dist/services/vector-cache.js.map +1 -0
- package/mcp-server/dist/services/whiteboard-geometry.d.ts +305 -0
- package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -0
- package/mcp-server/dist/services/whiteboard-geometry.js +666 -0
- package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -0
- package/mcp-server/dist/tools/agents.d.ts +212 -0
- package/mcp-server/dist/tools/agents.d.ts.map +1 -0
- package/mcp-server/dist/tools/agents.js +407 -0
- package/mcp-server/dist/tools/agents.js.map +1 -0
- package/mcp-server/dist/tools/bluesky.d.ts +39 -0
- package/mcp-server/dist/tools/bluesky.d.ts.map +1 -0
- package/mcp-server/dist/tools/bluesky.js +1144 -0
- package/mcp-server/dist/tools/bluesky.js.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts +58 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.js +106 -0
- package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
- package/mcp-server/dist/tools/browser-batch.d.ts +99 -0
- package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-batch.js +187 -0
- package/mcp-server/dist/tools/browser-batch.js.map +1 -0
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts +21 -0
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-cheatsheet.js +144 -0
- package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -0
- package/mcp-server/dist/tools/browser-interact.d.ts +239 -0
- package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-interact.js +300 -0
- package/mcp-server/dist/tools/browser-interact.js.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts +90 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.js +142 -0
- package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
- package/mcp-server/dist/tools/browser-observe.d.ts +561 -0
- package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-observe.js +2009 -0
- package/mcp-server/dist/tools/browser-observe.js.map +1 -0
- package/mcp-server/dist/tools/browser.d.ts +34 -0
- package/mcp-server/dist/tools/browser.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser.js +112 -0
- package/mcp-server/dist/tools/browser.js.map +1 -0
- package/mcp-server/dist/tools/card-tools.d.ts +82 -0
- package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/card-tools.js +137 -0
- package/mcp-server/dist/tools/card-tools.js.map +1 -0
- package/mcp-server/dist/tools/card.d.ts +8 -0
- package/mcp-server/dist/tools/card.d.ts.map +1 -0
- package/mcp-server/dist/tools/card.js +16 -0
- package/mcp-server/dist/tools/card.js.map +1 -0
- package/mcp-server/dist/tools/category.d.ts +32 -0
- package/mcp-server/dist/tools/category.d.ts.map +1 -0
- package/mcp-server/dist/tools/category.js +290 -0
- package/mcp-server/dist/tools/category.js.map +1 -0
- package/mcp-server/dist/tools/choice.d.ts +160 -0
- package/mcp-server/dist/tools/choice.d.ts.map +1 -0
- package/mcp-server/dist/tools/choice.js +80 -0
- package/mcp-server/dist/tools/choice.js.map +1 -0
- package/mcp-server/dist/tools/computer.d.ts +71 -0
- package/mcp-server/dist/tools/computer.d.ts.map +1 -0
- package/mcp-server/dist/tools/computer.js +174 -0
- package/mcp-server/dist/tools/computer.js.map +1 -0
- package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
- package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-channel.js +236 -0
- package/mcp-server/dist/tools/discord-channel.js.map +1 -0
- package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
- package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-guild.js +138 -0
- package/mcp-server/dist/tools/discord-guild.js.map +1 -0
- package/mcp-server/dist/tools/discord-member.d.ts +26 -0
- package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-member.js +122 -0
- package/mcp-server/dist/tools/discord-member.js.map +1 -0
- package/mcp-server/dist/tools/discord-message.d.ts +36 -0
- package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-message.js +184 -0
- package/mcp-server/dist/tools/discord-message.js.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.js +179 -0
- package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
- package/mcp-server/dist/tools/discord-role.d.ts +34 -0
- package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-role.js +178 -0
- package/mcp-server/dist/tools/discord-role.js.map +1 -0
- package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
- package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-thread.js +88 -0
- package/mcp-server/dist/tools/discord-thread.js.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.js +154 -0
- package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
- package/mcp-server/dist/tools/discord.d.ts +7 -0
- package/mcp-server/dist/tools/discord.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord.js +25 -0
- package/mcp-server/dist/tools/discord.js.map +1 -0
- package/mcp-server/dist/tools/fb-groups.d.ts +98 -0
- package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -0
- package/mcp-server/dist/tools/fb-groups.js +369 -0
- package/mcp-server/dist/tools/fb-groups.js.map +1 -0
- package/mcp-server/dist/tools/forget.d.ts +14 -0
- package/mcp-server/dist/tools/forget.d.ts.map +1 -0
- package/mcp-server/dist/tools/forget.js +26 -0
- package/mcp-server/dist/tools/forget.js.map +1 -0
- package/mcp-server/dist/tools/git-tools.d.ts +24 -0
- package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/git-tools.js +107 -0
- package/mcp-server/dist/tools/git-tools.js.map +1 -0
- package/mcp-server/dist/tools/git.d.ts +7 -0
- package/mcp-server/dist/tools/git.d.ts.map +1 -0
- package/mcp-server/dist/tools/git.js +11 -0
- package/mcp-server/dist/tools/git.js.map +1 -0
- package/mcp-server/dist/tools/gsc/experience.d.ts +135 -0
- package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/experience.js +351 -0
- package/mcp-server/dist/tools/gsc/experience.js.map +1 -0
- package/mcp-server/dist/tools/gsc/helpers.d.ts +78 -0
- package/mcp-server/dist/tools/gsc/helpers.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/helpers.js +258 -0
- package/mcp-server/dist/tools/gsc/helpers.js.map +1 -0
- package/mcp-server/dist/tools/gsc/indexing.d.ts +121 -0
- package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/indexing.js +357 -0
- package/mcp-server/dist/tools/gsc/indexing.js.map +1 -0
- package/mcp-server/dist/tools/gsc/inspect.d.ts +78 -0
- package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/inspect.js +321 -0
- package/mcp-server/dist/tools/gsc/inspect.js.map +1 -0
- package/mcp-server/dist/tools/gsc/navigate.d.ts +44 -0
- package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/navigate.js +197 -0
- package/mcp-server/dist/tools/gsc/navigate.js.map +1 -0
- package/mcp-server/dist/tools/gsc/performance.d.ts +84 -0
- package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/performance.js +274 -0
- package/mcp-server/dist/tools/gsc/performance.js.map +1 -0
- package/mcp-server/dist/tools/gsc/settings.d.ts +153 -0
- package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/settings.js +425 -0
- package/mcp-server/dist/tools/gsc/settings.js.map +1 -0
- package/mcp-server/dist/tools/gsc.d.ts +7 -0
- package/mcp-server/dist/tools/gsc.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc.js +51 -0
- package/mcp-server/dist/tools/gsc.js.map +1 -0
- package/mcp-server/dist/tools/image-tools.d.ts +24 -0
- package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/image-tools.js +80 -0
- package/mcp-server/dist/tools/image-tools.js.map +1 -0
- package/mcp-server/dist/tools/image.d.ts +10 -0
- package/mcp-server/dist/tools/image.d.ts.map +1 -0
- package/mcp-server/dist/tools/image.js +14 -0
- package/mcp-server/dist/tools/image.js.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +97 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js +228 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
- package/mcp-server/dist/tools/leonardo.d.ts +13 -0
- package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo.js +21 -0
- package/mcp-server/dist/tools/leonardo.js.map +1 -0
- package/mcp-server/dist/tools/loop.d.ts +30 -0
- package/mcp-server/dist/tools/loop.d.ts.map +1 -0
- package/mcp-server/dist/tools/loop.js +401 -0
- package/mcp-server/dist/tools/loop.js.map +1 -0
- package/mcp-server/dist/tools/memories.d.ts +56 -0
- package/mcp-server/dist/tools/memories.d.ts.map +1 -0
- package/mcp-server/dist/tools/memories.js +171 -0
- package/mcp-server/dist/tools/memories.js.map +1 -0
- package/mcp-server/dist/tools/morelogin.d.ts +24 -0
- package/mcp-server/dist/tools/morelogin.d.ts.map +1 -0
- package/mcp-server/dist/tools/morelogin.js +79 -0
- package/mcp-server/dist/tools/morelogin.js.map +1 -0
- package/mcp-server/dist/tools/profile.d.ts +17 -0
- package/mcp-server/dist/tools/profile.d.ts.map +1 -0
- package/mcp-server/dist/tools/profile.js +114 -0
- package/mcp-server/dist/tools/profile.js.map +1 -0
- package/mcp-server/dist/tools/recall.d.ts +71 -0
- package/mcp-server/dist/tools/recall.d.ts.map +1 -0
- package/mcp-server/dist/tools/recall.js +147 -0
- package/mcp-server/dist/tools/recall.js.map +1 -0
- package/mcp-server/dist/tools/reflect.d.ts +56 -0
- package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
- package/mcp-server/dist/tools/reflect.js +157 -0
- package/mcp-server/dist/tools/reflect.js.map +1 -0
- package/mcp-server/dist/tools/remember.d.ts +62 -0
- package/mcp-server/dist/tools/remember.d.ts.map +1 -0
- package/mcp-server/dist/tools/remember.js +201 -0
- package/mcp-server/dist/tools/remember.js.map +1 -0
- package/mcp-server/dist/tools/response.d.ts +23 -0
- package/mcp-server/dist/tools/response.d.ts.map +1 -0
- package/mcp-server/dist/tools/response.js +24 -0
- package/mcp-server/dist/tools/response.js.map +1 -0
- package/mcp-server/dist/tools/restore.d.ts +14 -0
- package/mcp-server/dist/tools/restore.d.ts.map +1 -0
- package/mcp-server/dist/tools/restore.js +23 -0
- package/mcp-server/dist/tools/restore.js.map +1 -0
- package/mcp-server/dist/tools/style-guide-tools.d.ts +51 -0
- package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/style-guide-tools.js +231 -0
- package/mcp-server/dist/tools/style-guide-tools.js.map +1 -0
- package/mcp-server/dist/tools/style-guide.d.ts +8 -0
- package/mcp-server/dist/tools/style-guide.d.ts.map +1 -0
- package/mcp-server/dist/tools/style-guide.js +12 -0
- package/mcp-server/dist/tools/style-guide.js.map +1 -0
- package/mcp-server/dist/tools/sync.d.ts +20 -0
- package/mcp-server/dist/tools/sync.d.ts.map +1 -0
- package/mcp-server/dist/tools/sync.js +156 -0
- package/mcp-server/dist/tools/sync.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
- package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
- package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe.js +11 -0
- package/mcp-server/dist/tools/tictactoe.js.map +1 -0
- package/mcp-server/dist/tools/utils.d.ts +19 -0
- package/mcp-server/dist/tools/utils.d.ts.map +1 -0
- package/mcp-server/dist/tools/utils.js +54 -0
- package/mcp-server/dist/tools/utils.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts +88 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.js +337 -0
- package/mcp-server/dist/tools/whiteboard-format.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts +326 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.js +222 -0
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
- package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard.js +16 -0
- package/mcp-server/dist/tools/whiteboard.js.map +1 -0
- package/mcp-server/dist/tools/youtube/analytics.d.ts +46 -0
- package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/analytics.js +83 -0
- package/mcp-server/dist/tools/youtube/analytics.js.map +1 -0
- package/mcp-server/dist/tools/youtube/dedup.d.ts +35 -0
- package/mcp-server/dist/tools/youtube/dedup.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/dedup.js +56 -0
- package/mcp-server/dist/tools/youtube/dedup.js.map +1 -0
- package/mcp-server/dist/tools/youtube/download.d.ts +31 -0
- package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/download.js +30 -0
- package/mcp-server/dist/tools/youtube/download.js.map +1 -0
- package/mcp-server/dist/tools/youtube/helpers.d.ts +66 -0
- package/mcp-server/dist/tools/youtube/helpers.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/helpers.js +143 -0
- package/mcp-server/dist/tools/youtube/helpers.js.map +1 -0
- package/mcp-server/dist/tools/youtube/media.d.ts +43 -0
- package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/media.js +97 -0
- package/mcp-server/dist/tools/youtube/media.js.map +1 -0
- package/mcp-server/dist/tools/youtube/metadata.d.ts +33 -0
- package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/metadata.js +106 -0
- package/mcp-server/dist/tools/youtube/metadata.js.map +1 -0
- package/mcp-server/dist/tools/youtube/navigate.d.ts +30 -0
- package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/navigate.js +71 -0
- package/mcp-server/dist/tools/youtube/navigate.js.map +1 -0
- package/mcp-server/dist/tools/youtube/playlist.d.ts +29 -0
- package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/playlist.js +128 -0
- package/mcp-server/dist/tools/youtube/playlist.js.map +1 -0
- package/mcp-server/dist/tools/youtube/schedule.d.ts +31 -0
- package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/schedule.js +147 -0
- package/mcp-server/dist/tools/youtube/schedule.js.map +1 -0
- package/mcp-server/dist/tools/youtube/source.d.ts +36 -0
- package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/source.js +85 -0
- package/mcp-server/dist/tools/youtube/source.js.map +1 -0
- package/mcp-server/dist/tools/youtube/upload.d.ts +53 -0
- package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/upload.js +255 -0
- package/mcp-server/dist/tools/youtube/upload.js.map +1 -0
- package/mcp-server/dist/tools/youtube.d.ts +12 -0
- package/mcp-server/dist/tools/youtube.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube.js +38 -0
- package/mcp-server/dist/tools/youtube.js.map +1 -0
- package/mcp-server/dist/tui.d.ts +3 -0
- package/mcp-server/dist/tui.d.ts.map +1 -0
- package/mcp-server/dist/tui.js +806 -0
- package/mcp-server/dist/tui.js.map +1 -0
- package/mcp-server/dist/types.d.ts +77 -0
- package/mcp-server/dist/types.d.ts.map +1 -0
- package/mcp-server/dist/types.js +2 -0
- package/mcp-server/dist/types.js.map +1 -0
- package/mcp-server/package-lock.json +4798 -0
- package/mcp-server/package.json +55 -38
- package/mcp-server/run.mjs +12 -2
- package/mcp-server/src/config.ts +48 -3
- package/mcp-server/src/http.ts +296 -93
- package/mcp-server/src/index.ts +546 -203
- package/mcp-server/src/preload-http.ts +19 -18
- package/mcp-server/src/preload.ts +19 -18
- package/mcp-server/src/scripts/setup-embeddings.ts +9 -0
- package/mcp-server/src/services/assistant-judgments.ts +157 -0
- package/mcp-server/src/services/browser-assist-gate.ts +383 -0
- package/mcp-server/src/services/browser-assist.ts +267 -0
- package/mcp-server/src/services/browser-bench.ts +477 -0
- package/mcp-server/src/services/browser-judgments.ts +414 -0
- package/mcp-server/src/services/browser-risk.ts +484 -0
- package/mcp-server/src/services/categories.ts +36 -19
- package/mcp-server/src/services/codex-config-heal.ts +403 -0
- package/mcp-server/src/services/desktop-assist-gate.ts +251 -0
- package/mcp-server/src/services/desktop-assist.ts +308 -0
- package/mcp-server/src/services/desktop-bench.ts +372 -0
- package/mcp-server/src/services/desktop-client.ts +76 -0
- package/mcp-server/src/services/desktop-judgments.ts +218 -0
- package/mcp-server/src/services/desktop-risk.ts +743 -0
- package/mcp-server/src/services/edit-stale.ts +299 -0
- package/mcp-server/src/services/embedding-service.ts +108 -0
- package/mcp-server/src/services/embedding-worker.ts +46 -0
- package/mcp-server/src/services/fb-regions.ts +221 -0
- package/mcp-server/src/services/file-checksums.ts +26 -9
- package/mcp-server/src/services/file-excerpt.ts +59 -0
- package/mcp-server/src/services/identity.ts +291 -0
- package/mcp-server/src/services/local-embeddings.ts +2 -77
- package/mcp-server/src/services/memory-client.ts +23 -0
- package/mcp-server/src/services/memory-judge-backfill.ts +462 -0
- package/mcp-server/src/services/memory-judgments.ts +901 -0
- package/mcp-server/src/services/memory-maintenance.ts +360 -0
- package/mcp-server/src/services/memory-retrieval.ts +349 -0
- package/mcp-server/src/services/memory-schema.ts +280 -0
- package/mcp-server/src/services/memory-sessions.ts +87 -0
- package/mcp-server/src/services/memory-triage.ts +182 -0
- package/mcp-server/src/services/memory-verification.ts +68 -0
- package/mcp-server/src/services/memory-writer.ts +125 -0
- package/mcp-server/src/services/neural-interface.ts +1217 -73
- package/mcp-server/src/services/profiles.ts +428 -0
- package/mcp-server/src/services/secret-gate.ts +52 -0
- package/mcp-server/src/services/server-instructions.ts +122 -0
- package/mcp-server/src/services/sqlite.ts +1456 -823
- package/mcp-server/src/services/typesafe-bench.ts +247 -0
- package/mcp-server/src/services/typesafe-config.ts +790 -0
- package/mcp-server/src/services/typesafe.ts +390 -0
- package/mcp-server/src/services/vector-cache.ts +255 -0
- package/mcp-server/src/services/whiteboard-geometry.ts +711 -0
- package/mcp-server/src/tools/agents.ts +539 -0
- package/mcp-server/src/tools/bluesky.ts +1186 -0
- package/mcp-server/src/tools/browser-advanced.ts +15 -6
- package/mcp-server/src/tools/browser-batch.ts +178 -0
- package/mcp-server/src/tools/browser-cheatsheet.ts +155 -0
- package/mcp-server/src/tools/browser-interact.ts +362 -255
- package/mcp-server/src/tools/browser-navigate.ts +164 -77
- package/mcp-server/src/tools/browser-observe.ts +2222 -1330
- package/mcp-server/src/tools/browser.ts +181 -107
- package/mcp-server/src/tools/card.ts +7 -5
- package/mcp-server/src/tools/choice.ts +103 -0
- package/mcp-server/src/tools/computer.ts +190 -0
- package/mcp-server/src/tools/discord.ts +10 -23
- package/mcp-server/src/tools/fb-groups.ts +387 -0
- package/mcp-server/src/tools/forget.ts +1 -1
- package/mcp-server/src/tools/git.ts +3 -1
- package/mcp-server/src/tools/gsc/experience.ts +440 -0
- package/mcp-server/src/tools/gsc/helpers.ts +288 -0
- package/mcp-server/src/tools/gsc/indexing.ts +495 -0
- package/mcp-server/src/tools/gsc/inspect.ts +391 -0
- package/mcp-server/src/tools/gsc/navigate.ts +236 -0
- package/mcp-server/src/tools/gsc/performance.ts +352 -0
- package/mcp-server/src/tools/gsc/settings.ts +564 -0
- package/mcp-server/src/tools/gsc.ts +95 -0
- package/mcp-server/src/tools/image.ts +3 -1
- package/mcp-server/src/tools/leonardo-browser-tools.ts +47 -37
- package/mcp-server/src/tools/leonardo.ts +7 -5
- package/mcp-server/src/tools/loop.ts +118 -25
- package/mcp-server/src/tools/memories.ts +65 -5
- package/mcp-server/src/tools/morelogin.ts +81 -0
- package/mcp-server/src/tools/profile.ts +122 -0
- package/mcp-server/src/tools/recall.ts +105 -261
- package/mcp-server/src/tools/reflect.ts +171 -145
- package/mcp-server/src/tools/remember.ts +223 -115
- package/mcp-server/src/tools/style-guide-tools.ts +250 -0
- package/mcp-server/src/tools/style-guide.ts +16 -0
- package/mcp-server/src/tools/sync.ts +66 -9
- package/mcp-server/src/tools/tictactoe.ts +3 -1
- package/mcp-server/src/tools/utils.ts +22 -0
- package/mcp-server/src/tools/whiteboard-format.ts +394 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +191 -183
- package/mcp-server/src/tools/whiteboard.ts +7 -5
- package/mcp-server/src/tools/youtube/analytics.ts +106 -0
- package/mcp-server/src/tools/youtube/dedup.ts +57 -0
- package/mcp-server/src/tools/youtube/download.ts +41 -0
- package/mcp-server/src/tools/youtube/helpers.ts +183 -0
- package/mcp-server/src/tools/youtube/media.ts +109 -0
- package/mcp-server/src/tools/youtube/metadata.ts +112 -0
- package/mcp-server/src/tools/youtube/navigate.ts +81 -0
- package/mcp-server/src/tools/youtube/playlist.ts +134 -0
- package/mcp-server/src/tools/youtube/schedule.ts +158 -0
- package/mcp-server/src/tools/youtube/source.ts +101 -0
- package/mcp-server/src/tools/youtube/upload.ts +296 -0
- package/mcp-server/src/tools/youtube.ts +47 -0
- package/mcp-server/src/tui.ts +8 -9
- package/neural-interface/games/TicTacToe/1.svg +1 -0
- package/neural-interface/games/TicTacToe/2.svg +1 -0
- package/neural-interface/games/TicTacToe/3.svg +1 -0
- package/neural-interface/games/TicTacToe/4.svg +1 -0
- package/neural-interface/games/TicTacToe/5.svg +1 -0
- package/neural-interface/games/TicTacToe/6.svg +1 -0
- package/neural-interface/games/TicTacToe/7.svg +1 -0
- package/neural-interface/games/TicTacToe/8.svg +1 -0
- package/neural-interface/games/TicTacToe/9.svg +1 -0
- package/neural-interface/games/TicTacToe/Board.svg +1 -0
- package/neural-interface/games/TicTacToe/Circle.svg +1 -0
- package/neural-interface/games/TicTacToe/Cross.svg +1 -0
- package/neural-interface/i18n/en.json +6983 -0
- package/neural-interface/i18n/pt-BR.json +6987 -0
- package/neural-interface/lib/assistant-api.js +1005 -0
- package/neural-interface/lib/assistant-attachments.js +121 -0
- package/neural-interface/lib/assistant-brains/claude.js +207 -0
- package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +43 -0
- package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +56 -0
- package/neural-interface/lib/assistant-brains/codex.js +213 -0
- package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +47 -0
- package/neural-interface/lib/assistant-brains/opencode-route-gate.js +62 -0
- package/neural-interface/lib/assistant-brains/opencode.js +320 -0
- package/neural-interface/lib/assistant-budget.js +318 -0
- package/neural-interface/lib/assistant-catalog.js +731 -0
- package/neural-interface/lib/assistant-clarify.js +664 -0
- package/neural-interface/lib/assistant-config.js +232 -0
- package/neural-interface/lib/assistant-dispatch.js +2744 -0
- package/neural-interface/lib/assistant-envelope.js +579 -0
- package/neural-interface/lib/assistant-evidence.js +236 -0
- package/neural-interface/lib/assistant-hidden-models.js +272 -0
- package/neural-interface/lib/assistant-jev.js +117 -0
- package/neural-interface/lib/assistant-judgments.js +111 -0
- package/neural-interface/lib/assistant-media.js +335 -0
- package/neural-interface/lib/assistant-memory.js +398 -0
- package/neural-interface/lib/assistant-persona.js +444 -0
- package/neural-interface/lib/assistant-plan-permissions.js +448 -0
- package/neural-interface/lib/assistant-playbooks/design.md +87 -0
- package/neural-interface/lib/assistant-playbooks.js +72 -0
- package/neural-interface/lib/assistant-pricing.js +159 -0
- package/neural-interface/lib/assistant-route-gate-codex.js +163 -0
- package/neural-interface/lib/assistant-route-gate.js +212 -0
- package/neural-interface/lib/assistant-router.js +1324 -0
- package/neural-interface/lib/assistant-runtime.js +3337 -0
- package/neural-interface/lib/assistant-task-prompt.js +472 -0
- package/neural-interface/lib/assistant-usage.js +1688 -0
- package/neural-interface/lib/augmented-path.js +50 -0
- package/neural-interface/lib/automation-storage-migration.js +54 -0
- package/neural-interface/lib/backup-service.js +851 -0
- package/neural-interface/lib/backup-zip-reader.js +262 -0
- package/neural-interface/lib/browser-assist-api.js +41 -0
- package/neural-interface/lib/browser-console.js +176 -0
- package/neural-interface/lib/browser-execution.js +241 -0
- package/neural-interface/lib/browser-extraction.js +191 -0
- package/neural-interface/lib/browser-screenshot.js +399 -0
- package/neural-interface/lib/browser-semantic-context.js +639 -0
- package/neural-interface/lib/browser-snapshots.js +192 -0
- package/neural-interface/lib/browser-tab-recovery.js +119 -0
- package/neural-interface/lib/browser-targets.js +70 -0
- package/neural-interface/lib/browser-tool-policy.js +926 -0
- package/neural-interface/lib/claude-accounts.js +230 -0
- package/neural-interface/lib/claude-agent-bridge.js +3296 -0
- package/neural-interface/lib/claude-bypass-policy.js +99 -0
- package/neural-interface/lib/claude-history.js +429 -0
- package/neural-interface/lib/claude-model-catalog.js +483 -0
- package/neural-interface/lib/claude-panel-requests.js +165 -0
- package/neural-interface/lib/claude-panel-session.js +272 -0
- package/neural-interface/lib/claude-permission-rules.js +153 -0
- package/neural-interface/lib/claude-session-cleanup.js +71 -0
- package/neural-interface/lib/claude-session-list.js +82 -0
- package/neural-interface/lib/claude-session-ops.js +140 -0
- package/neural-interface/lib/claude-settings-view.js +51 -0
- package/neural-interface/lib/claude-skin-connect.js +79 -0
- package/neural-interface/lib/claude-stall-policy.js +7 -0
- package/neural-interface/lib/claude-temporary.js +435 -0
- package/neural-interface/lib/cli-update-planner.js +312 -0
- package/neural-interface/lib/codex-capabilities.d.ts +41 -0
- package/neural-interface/lib/codex-capabilities.js +148 -0
- package/neural-interface/lib/codex-collaboration-mode.js +17 -0
- package/neural-interface/lib/codex-compat-rpc.js +241 -0
- package/neural-interface/lib/codex-mcp-approvals.js +56 -0
- package/neural-interface/lib/codex-mcp-apps.js +47 -0
- package/neural-interface/lib/codex-model-context.js +202 -0
- package/neural-interface/lib/codex-plan-store.js +71 -0
- package/neural-interface/lib/codex-request-journal.js +242 -0
- package/neural-interface/lib/codex-runtime-path.js +213 -0
- package/neural-interface/lib/codex-session-catalog.js +231 -0
- package/neural-interface/lib/codex-session-settings.js +46 -0
- package/neural-interface/lib/codex-sidepanel-permissions.js +110 -0
- package/neural-interface/lib/codex-thread-history.js +165 -0
- package/neural-interface/lib/codex-user-verification.js +34 -0
- package/neural-interface/lib/codex-writer-lifecycle.js +152 -0
- package/neural-interface/lib/confined-fs.js +256 -0
- package/neural-interface/lib/db.js +922 -0
- package/neural-interface/lib/design-md.js +9 -0
- package/neural-interface/lib/desktop/api.js +92 -0
- package/neural-interface/lib/desktop/audit.js +62 -0
- package/neural-interface/lib/desktop/build.js +423 -0
- package/neural-interface/lib/desktop/config.js +606 -0
- package/neural-interface/lib/desktop/coords.js +76 -0
- package/neural-interface/lib/desktop/fake-helper.js +1192 -0
- package/neural-interface/lib/desktop/frames.js +77 -0
- package/neural-interface/lib/desktop/grants.js +84 -0
- package/neural-interface/lib/desktop/guards.js +170 -0
- package/neural-interface/lib/desktop/helper/SynabunDesktop.swift +3056 -0
- package/neural-interface/lib/desktop/lease.js +51 -0
- package/neural-interface/lib/desktop/manager.js +545 -0
- package/neural-interface/lib/desktop/press-contexts.js +103 -0
- package/neural-interface/lib/desktop/protocol.js +495 -0
- package/neural-interface/lib/desktop/service.js +1202 -0
- package/neural-interface/lib/effort-levels.js +148 -0
- package/neural-interface/lib/event-loop-monitor.js +67 -0
- package/neural-interface/lib/exec-loop-command.js +209 -0
- package/neural-interface/lib/folder-picker.ps1 +77 -0
- package/neural-interface/lib/http-guards.js +287 -0
- package/neural-interface/lib/jsonl-lines.js +94 -0
- package/neural-interface/lib/loop-logger.js +127 -0
- package/neural-interface/lib/mcp-client-config.js +133 -0
- package/neural-interface/lib/mcp-installer.js +487 -0
- package/neural-interface/lib/mcp-profile-refresh-coordinator.js +78 -0
- package/neural-interface/lib/mcp-profile-servers.js +120 -0
- package/neural-interface/lib/memo-cache.js +84 -0
- package/neural-interface/lib/memory-api.js +758 -0
- package/neural-interface/lib/memory-map-api.js +1150 -0
- package/neural-interface/lib/memory-map-layout.js +872 -0
- package/neural-interface/lib/memory-map-worker.js +165 -0
- package/neural-interface/lib/memory-session-capture.js +81 -0
- package/neural-interface/lib/memory-timers.js +62 -0
- package/neural-interface/lib/morelogin.js +301 -0
- package/neural-interface/lib/native-binary-runtime.js +650 -0
- package/neural-interface/lib/native-loop-goal.js +33 -0
- package/neural-interface/lib/native-loop-providers.js +1016 -0
- package/neural-interface/lib/native-loop-runtime.js +747 -0
- package/neural-interface/lib/opencode-hidden-models.js +113 -0
- package/neural-interface/lib/opencode-history.js +253 -0
- package/neural-interface/lib/opencode-runtime-config.js +58 -0
- package/neural-interface/lib/opencode-v2-client.js +486 -0
- package/neural-interface/lib/opencode-v2-ws-requests.js +955 -0
- package/neural-interface/lib/path-confine.js +213 -0
- package/neural-interface/lib/plugin-installer.js +264 -0
- package/neural-interface/lib/project-storage.js +766 -0
- package/neural-interface/lib/pty-host/core.js +603 -0
- package/neural-interface/lib/pty-host/flow.js +43 -0
- package/neural-interface/lib/pty-host/host.js +47 -0
- package/neural-interface/lib/pty-host/manager.js +360 -0
- package/neural-interface/lib/pty-host/protocol.js +81 -0
- package/neural-interface/lib/pty-host/snapshot.js +133 -0
- package/neural-interface/lib/remote-policy.js +582 -0
- package/neural-interface/lib/rulesets/api.js +175 -0
- package/neural-interface/lib/rulesets/fs-safe.js +245 -0
- package/neural-interface/lib/rulesets/installer.js +1453 -0
- package/neural-interface/lib/rulesets/json-edit.js +186 -0
- package/neural-interface/lib/rulesets/lock.js +170 -0
- package/neural-interface/lib/rulesets/managed-text.js +314 -0
- package/neural-interface/lib/rulesets/normalise.js +24 -0
- package/neural-interface/lib/rulesets/render.js +84 -0
- package/neural-interface/lib/schedule-launch-intent.js +40 -0
- package/neural-interface/lib/session-cache-builder.js +524 -0
- package/neural-interface/lib/session-chunker.js +344 -0
- package/neural-interface/lib/session-indexer.js +327 -0
- package/neural-interface/lib/session-title-generator.js +227 -0
- package/neural-interface/lib/skill-prompt-bridge.js +11 -0
- package/neural-interface/lib/style-guide/api.js +375 -0
- package/neural-interface/lib/style-guide/color.js +352 -0
- package/neural-interface/lib/style-guide/export-css.js +99 -0
- package/neural-interface/lib/style-guide/export-dtcg.js +209 -0
- package/neural-interface/lib/style-guide/export-tailwind.js +102 -0
- package/neural-interface/lib/style-guide/fonts.json +227 -0
- package/neural-interface/lib/style-guide/import.js +1268 -0
- package/neural-interface/lib/style-guide/pointers.js +103 -0
- package/neural-interface/lib/style-guide/presets.js +232 -0
- package/neural-interface/lib/style-guide/render-design-md.js +319 -0
- package/neural-interface/lib/style-guide/schema.js +848 -0
- package/neural-interface/lib/style-guide/security.js +43 -0
- package/neural-interface/lib/style-guide/store.js +491 -0
- package/neural-interface/lib/style-guide/summary.js +106 -0
- package/neural-interface/lib/style-guide/tokens.js +170 -0
- package/neural-interface/lib/synabun-update-plan.js +305 -0
- package/neural-interface/lib/synabun-version.js +179 -0
- package/neural-interface/lib/system-restore.js +99 -0
- package/neural-interface/lib/terminal-env.js +86 -0
- package/neural-interface/lib/ui-blob-store.js +106 -0
- package/neural-interface/lib/vector-cache.js +2 -0
- package/neural-interface/lib/virtual-socket.js +60 -0
- package/neural-interface/lib/whatsapp/api.js +146 -0
- package/neural-interface/lib/whatsapp/auth-store.js +389 -0
- package/neural-interface/lib/whatsapp/baileys-adapter.js +457 -0
- package/neural-interface/lib/whatsapp/brain.js +128 -0
- package/neural-interface/lib/whatsapp/bridge.js +1499 -0
- package/neural-interface/lib/whatsapp/cards.js +519 -0
- package/neural-interface/lib/whatsapp/commands.js +92 -0
- package/neural-interface/lib/whatsapp/config.js +291 -0
- package/neural-interface/lib/whatsapp/connector/entry.mjs +25 -0
- package/neural-interface/lib/whatsapp/connector/manifest.json +6 -0
- package/neural-interface/lib/whatsapp/connector/npm-shrinkwrap.json +717 -0
- package/neural-interface/lib/whatsapp/connector/package.json +8 -0
- package/neural-interface/lib/whatsapp/fake-baileys.js +444 -0
- package/neural-interface/lib/whatsapp/format.js +552 -0
- package/neural-interface/lib/whatsapp/host-core.js +1225 -0
- package/neural-interface/lib/whatsapp/host.js +119 -0
- package/neural-interface/lib/whatsapp/identity.js +288 -0
- package/neural-interface/lib/whatsapp/inbound.js +149 -0
- package/neural-interface/lib/whatsapp/installer.js +562 -0
- package/neural-interface/lib/whatsapp/manager.js +638 -0
- package/neural-interface/lib/whatsapp/paths.js +81 -0
- package/neural-interface/lib/whatsapp/protocol.js +327 -0
- package/neural-interface/lib/whatsapp/qr-svg.js +57 -0
- package/neural-interface/lib/whatsapp/redact.js +130 -0
- package/neural-interface/lib/whatsapp/service.js +1305 -0
- package/neural-interface/lib/whatsapp/vendor/qrcodegen.js +848 -0
- package/neural-interface/package-lock.json +4682 -0
- package/neural-interface/package.json +58 -27
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +1 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index.html +32 -182
- package/neural-interface/public/index2d.html +2 -2
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/mcp.svg +4 -0
- package/neural-interface/public/offline.html +335 -299
- package/neural-interface/public/onboarding.html +692 -155
- package/neural-interface/public/opencode-logo-dark-square.svg +1 -0
- package/neural-interface/public/shared/agent-runtime-options.js +596 -0
- package/neural-interface/public/shared/api.js +339 -28
- package/neural-interface/public/shared/assistant/asst-accounts.js +318 -0
- package/neural-interface/public/shared/assistant/asst-agents-dock.js +296 -0
- package/neural-interface/public/shared/assistant/asst-api.js +270 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +744 -0
- package/neural-interface/public/shared/assistant/asst-budget.js +333 -0
- package/neural-interface/public/shared/assistant/asst-composer.js +656 -0
- package/neural-interface/public/shared/assistant/asst-computer.js +803 -0
- package/neural-interface/public/shared/assistant/asst-control.js +1018 -0
- package/neural-interface/public/shared/assistant/asst-hosts.js +262 -0
- package/neural-interface/public/shared/assistant/asst-mascot.js +311 -0
- package/neural-interface/public/shared/assistant/asst-menu.js +375 -0
- package/neural-interface/public/shared/assistant/asst-panel.js +1845 -0
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +589 -0
- package/neural-interface/public/shared/assistant/asst-render.js +3296 -0
- package/neural-interface/public/shared/assistant/asst-route.js +1653 -0
- package/neural-interface/public/shared/assistant/asst-roving.js +75 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +117 -0
- package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +488 -0
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +675 -0
- package/neural-interface/public/shared/assistant/asst-slash.js +258 -0
- package/neural-interface/public/shared/assistant/asst-state.js +727 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +1684 -0
- package/neural-interface/public/shared/assistant/asst-tool-kinds.js +1017 -0
- package/neural-interface/public/shared/assistant/asst-usage.js +444 -0
- package/neural-interface/public/shared/assistant/asst-ws.js +175 -0
- package/neural-interface/public/shared/automation-context.js +75 -0
- package/neural-interface/public/shared/blob-store.js +99 -0
- package/neural-interface/public/shared/cdx/cdx-capabilities.js +118 -0
- package/neural-interface/public/shared/cdx/cdx-cards.js +37 -0
- package/neural-interface/public/shared/cdx/cdx-commands.js +171 -0
- package/neural-interface/public/shared/cdx/cdx-context-menu.js +402 -0
- package/neural-interface/public/shared/cdx/cdx-context-model.js +220 -0
- package/neural-interface/public/shared/cdx/cdx-features.js +408 -0
- package/neural-interface/public/shared/cdx/cdx-icons.js +89 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-host.js +414 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.html +6 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.js +56 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app.js +178 -0
- package/neural-interface/public/shared/cdx/cdx-model-catalog.js +120 -0
- package/neural-interface/public/shared/cdx/cdx-output-model.js +261 -0
- package/neural-interface/public/shared/cdx/cdx-output.js +382 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +1372 -0
- package/neural-interface/public/shared/cdx/cdx-plan.js +266 -0
- package/neural-interface/public/shared/cdx/cdx-protocol.js +682 -0
- package/neural-interface/public/shared/cdx/cdx-render.js +3381 -0
- package/neural-interface/public/shared/cdx/cdx-requests.js +1136 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +3251 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +7992 -0
- package/neural-interface/public/shared/cdx/cdx-ws.js +363 -0
- package/neural-interface/public/shared/cli-status.js +169 -0
- package/neural-interface/public/shared/constants.js +22 -5
- package/neural-interface/public/shared/cp/cp-agents.js +186 -0
- package/neural-interface/public/shared/cp/cp-bash.js +252 -0
- package/neural-interface/public/shared/cp/cp-compose.js +159 -0
- package/neural-interface/public/shared/cp/cp-context-menu.js +309 -0
- package/neural-interface/public/shared/cp/cp-context-model.js +157 -0
- package/neural-interface/public/shared/cp/cp-context-window.js +80 -0
- package/neural-interface/public/shared/cp/cp-ctx.js +11 -0
- package/neural-interface/public/shared/cp/cp-diff.js +255 -0
- package/neural-interface/public/shared/cp/cp-event-rows.js +637 -0
- package/neural-interface/public/shared/cp/cp-events.js +846 -0
- package/neural-interface/public/shared/cp/cp-markdown.js +104 -0
- package/neural-interface/public/shared/cp/cp-permission-model.js +905 -0
- package/neural-interface/public/shared/cp/cp-permissions.js +626 -0
- package/neural-interface/public/shared/cp/cp-rehydrate.js +113 -0
- package/neural-interface/public/shared/cp/cp-restore.js +224 -0
- package/neural-interface/public/shared/cp/cp-session-model.js +338 -0
- package/neural-interface/public/shared/cp/cp-session-settings.js +254 -0
- package/neural-interface/public/shared/cp/cp-sessions.js +227 -0
- package/neural-interface/public/shared/cp/cp-statusline.js +153 -0
- package/neural-interface/public/shared/cp/cp-styles.js +349 -0
- package/neural-interface/public/shared/cp/cp-tasks-model.js +282 -0
- package/neural-interface/public/shared/cp/cp-tasks.js +107 -0
- package/neural-interface/public/shared/cp/cp-temporary-view.js +56 -0
- package/neural-interface/public/shared/cp/cp-temporary.js +231 -0
- package/neural-interface/public/shared/cp/cp-tool-cards.js +326 -0
- package/neural-interface/public/shared/cp/cp-tool-results.js +618 -0
- package/neural-interface/public/shared/cp/cp-usage-model.js +124 -0
- package/neural-interface/public/shared/graph-activity.js +68 -0
- package/neural-interface/public/shared/html-shell.js +107 -12
- package/neural-interface/public/shared/i18n.js +132 -34
- package/neural-interface/public/shared/node-positions-store.js +44 -0
- package/neural-interface/public/shared/ocp-hidden-models.js +116 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +355 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-autoaccept-button.js +35 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-automation-ownership.js +72 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +280 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +67 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +179 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes.js +164 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +725 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +75 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +1204 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm-logic.js +117 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm.js +59 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-gauge.js +252 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +478 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +179 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +233 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-icons.js +30 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +1065 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-mention-hints.js +182 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +655 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +2636 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +601 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-profile-inheritance.js +78 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +649 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +35 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +357 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render-logic.js +189 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1869 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-select.js +42 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +117 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +1714 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +468 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-widgets.js +63 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +366 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-settings-link.js +19 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +343 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +746 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +347 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +338 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +3543 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-subagents-logic.js +482 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-tools-logic.js +351 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-tools.js +168 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-variantpicker.js +315 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +545 -0
- package/neural-interface/public/shared/provider-icons.js +30 -0
- package/neural-interface/public/shared/session-title.js +100 -0
- package/neural-interface/public/shared/settings/settings-ia.js +389 -0
- package/neural-interface/public/shared/settings/settings-kit.js +234 -0
- package/neural-interface/public/shared/settings/settings-search.js +463 -0
- package/neural-interface/public/shared/settings/settings-shell.js +570 -0
- package/neural-interface/public/shared/state.js +5 -0
- package/neural-interface/public/shared/storage.js +50 -7
- package/neural-interface/public/shared/styleguide/sg-agents.js +42 -0
- package/neural-interface/public/shared/styleguide/sg-brand.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-color-client.js +388 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +148 -0
- package/neural-interface/public/shared/styleguide/sg-components.js +35 -0
- package/neural-interface/public/shared/styleguide/sg-history.js +41 -0
- package/neural-interface/public/shared/styleguide/sg-icons-logo.js +38 -0
- package/neural-interface/public/shared/styleguide/sg-imagery.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-import-export.js +47 -0
- package/neural-interface/public/shared/styleguide/sg-kit.js +281 -0
- package/neural-interface/public/shared/styleguide/sg-labels.js +318 -0
- package/neural-interface/public/shared/styleguide/sg-layout.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-motion.js +5 -0
- package/neural-interface/public/shared/styleguide/sg-panel.js +229 -0
- package/neural-interface/public/shared/styleguide/sg-preview.js +138 -0
- package/neural-interface/public/shared/styleguide/sg-rules.js +9 -0
- package/neural-interface/public/shared/styleguide/sg-shape.js +24 -0
- package/neural-interface/public/shared/styleguide/sg-state.js +132 -0
- package/neural-interface/public/shared/styleguide/sg-typography.js +124 -0
- package/neural-interface/public/shared/styles.css +7883 -1155
- package/neural-interface/public/shared/synabun-mascot.js +1625 -0
- package/neural-interface/public/shared/synabun-ticker.js +376 -0
- package/neural-interface/public/shared/term-renderer.js +343 -0
- package/neural-interface/public/shared/ui-assistant-panel.js +10 -0
- package/neural-interface/public/shared/ui-assistant.js +77 -0
- package/neural-interface/public/shared/ui-automation-studio.js +5105 -3573
- package/neural-interface/public/shared/ui-claude-panel.js +11685 -6333
- package/neural-interface/public/shared/ui-codex-panel.js +12 -0
- package/neural-interface/public/shared/ui-cost-widget.js +55 -34
- package/neural-interface/public/shared/ui-detail.js +77 -14
- package/neural-interface/public/shared/ui-explorer.js +360 -181
- package/neural-interface/public/shared/ui-file-explorer.js +485 -107
- package/neural-interface/public/shared/ui-help.js +2 -2
- package/neural-interface/public/shared/ui-image-gallery.js +45 -21
- package/neural-interface/public/shared/ui-judgments.js +1346 -0
- package/neural-interface/public/shared/ui-keybinds.js +3 -2
- package/neural-interface/public/shared/ui-link.js +105 -51
- package/neural-interface/public/shared/ui-loading.js +4 -4
- package/neural-interface/public/shared/ui-memory-maintenance.js +111 -0
- package/neural-interface/public/shared/ui-menubar.js +89 -4
- package/neural-interface/public/shared/ui-native-loop-router-state.js +94 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +493 -0
- package/neural-interface/public/shared/ui-native-window-id.js +7 -0
- package/neural-interface/public/shared/ui-navbar.js +357 -222
- package/neural-interface/public/shared/ui-notifications.js +58 -12
- package/neural-interface/public/shared/ui-opencode-panel-v2.js +17 -0
- package/neural-interface/public/shared/ui-panels.js +15 -7
- package/neural-interface/public/shared/ui-resume.js +823 -653
- package/neural-interface/public/shared/ui-schedule-queue.js +172 -0
- package/neural-interface/public/shared/ui-schedules-studio.js +1882 -0
- package/neural-interface/public/shared/ui-scroll-follow.js +194 -0
- package/neural-interface/public/shared/ui-sessions.js +789 -34
- package/neural-interface/public/shared/ui-settings.js +5793 -2190
- package/neural-interface/public/shared/ui-sidepanel-layout.js +109 -0
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +19 -0
- package/neural-interface/public/shared/ui-sidepanel-session-entry.js +40 -0
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +221 -0
- package/neural-interface/public/shared/ui-sidepanel-state.js +131 -0
- package/neural-interface/public/shared/ui-sidepanel-tray.js +547 -0
- package/neural-interface/public/shared/ui-sidepanel-windows.js +387 -0
- package/neural-interface/public/shared/ui-skills.js +18 -32
- package/neural-interface/public/shared/ui-styleguide.js +35 -0
- package/neural-interface/public/shared/ui-sync.js +87 -0
- package/neural-interface/public/shared/ui-terminal.js +7877 -6385
- package/neural-interface/public/shared/ui-tool-updates.js +292 -0
- package/neural-interface/public/shared/ui-tooltip.js +156 -15
- package/neural-interface/public/shared/ui-tutorial-draw.js +119 -683
- package/neural-interface/public/shared/ui-tutorial-layout.js +135 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +59 -2209
- package/neural-interface/public/shared/ui-tutorial.css +102 -0
- package/neural-interface/public/shared/ui-tutorial.js +557 -1076
- package/neural-interface/public/shared/ui-update.js +420 -68
- package/neural-interface/public/shared/ui-whatsapp.js +941 -0
- package/neural-interface/public/shared/ui-whiteboard.js +1076 -233
- package/neural-interface/public/shared/ui-workspaces.js +2 -1
- package/neural-interface/public/shared/utils.js +58 -0
- package/neural-interface/public/shared/vterm-buffer.js +15 -5
- package/neural-interface/public/shared/whatsapp/wa-phone.js +65 -0
- package/neural-interface/public/shared/whatsapp/wa-view.js +411 -0
- package/neural-interface/public/sidepanel-session.html +18 -0
- package/neural-interface/public/sw.js +8 -2
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/variant/2d/graph.js +1707 -1635
- package/neural-interface/public/variant/2d/lasso.js +2 -1
- package/neural-interface/public/variant/2d/layout.js +595 -594
- package/neural-interface/public/variant/2d/main.js +556 -521
- package/neural-interface/public/variant/2d/settings-gfx.js +18 -24
- package/neural-interface/public/variant/3d/main.js +516 -500
- package/neural-interface/public/variant/3d/map-controls.js +144 -0
- package/neural-interface/public/variant/3d/map-data.js +251 -0
- package/neural-interface/public/variant/3d/map-labels.js +241 -0
- package/neural-interface/public/variant/3d/map-pick.js +90 -0
- package/neural-interface/public/variant/3d/map-renderer.js +521 -0
- package/neural-interface/public/variant/3d/map-shaders.js +158 -0
- package/neural-interface/public/variant/3d/map-view.js +618 -0
- package/neural-interface/public/vendor/three/manifest.json +3 -0
- package/neural-interface/public/vendor/three/three-0.169.0.min.js +3844 -0
- package/neural-interface/public/vendor/xterm/manifest.json +3 -0
- package/neural-interface/public/vendor/xterm/xterm-6.0.0.css +285 -0
- package/neural-interface/public/vendor/xterm/xterm-6.0.0.min.js +192 -0
- package/neural-interface/scripts/build-vendor.mjs +64 -0
- package/neural-interface/scripts/codex-protocol-fixture.mjs +83 -0
- package/neural-interface/scripts/rebuild-pty.js +37 -23
- package/neural-interface/scripts/vendor-three-entry.js +24 -0
- package/neural-interface/scripts/vendor-xterm-entry.js +8 -0
- package/neural-interface/server.js +33948 -15000
- package/neural-interface/skins/default/skin.css +90 -0
- package/neural-interface/skins/default/skin.json +8 -0
- package/neural-interface/templates/rulesets/README.md +9 -0
- package/neural-interface/templates/rulesets/claude.md +3 -0
- package/neural-interface/templates/rulesets/codex.md +2 -0
- package/neural-interface/templates/rulesets/coexistence.md +6 -0
- package/neural-interface/templates/rulesets/core.md +26 -0
- package/neural-interface/templates/rulesets/cursor.md +2 -0
- package/neural-interface/templates/rulesets/gemini.md +2 -0
- package/neural-interface/templates/rulesets/legacy-hashes.json +227 -0
- package/neural-interface/templates/rulesets/manifest.json +12 -0
- package/neural-interface/templates/rulesets/opencode.md +2 -0
- package/package.json +119 -53
- package/postinstall.js +125 -26
- package/preuninstall.js +187 -0
- package/scripts/audit-claude-hook-usage.mjs +12 -0
- package/scripts/audit-memory-duplicates.mjs +44 -0
- package/scripts/measure-claude-hook-overhead.mjs +77 -0
- package/scripts/recover-user-config.mjs +254 -0
- package/scripts/restore-data-home-from-backup.mjs +239 -0
- package/setup.js +804 -260
- package/skills/leonardo/SKILL.md +19 -10
- package/skills/leonardo/modules/image-prompter.md +18 -12
- package/skills/leonardo/modules/video-prompter.md +19 -13
- package/skills/synabun/SKILL.md +209 -185
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +380 -374
- package/skills/synabun/modules/changelog.md +15 -6
- package/skills/synabun/modules/idea.md +148 -144
- package/skills/synabun/modules/memorize.md +13 -4
- package/skills/synabun/modules/schedule.md +198 -0
- package/skills/synabun/synabunicon.png +0 -0
- package/updater.cmd +31 -0
- package/updater.mjs +382 -0
- package/updater.sh +29 -0
- package/hooks/claude-code/test-user-learning.mjs +0 -531
- package/neural-interface/public/claude-chat.css +0 -878
- package/neural-interface/public/claude-chat.html +0 -92
- package/neural-interface/public/claude-chat.js +0 -1168
- package/neural-interface/public/index-legacy.html +0 -14677
- package/neural-interface/public/index2d-legacy.html +0 -8193
- package/neural-interface/public/shared/html-term-renderer.js +0 -1022
- package/neural-interface/public/variant/3d/background.js +0 -884
- package/neural-interface/public/variant/3d/camera.js +0 -661
- package/neural-interface/public/variant/3d/gfx.js +0 -116
- package/neural-interface/public/variant/3d/graph.js +0 -2399
- package/neural-interface/public/variant/3d/settings-gfx.js +0 -228
- package/neural-interface/templates/CLAUDE-template.md +0 -211
|
@@ -0,0 +1,3056 @@
|
|
|
1
|
+
// SynaBun desktop helper — the native macOS backend of the Assistant's computer use.
|
|
2
|
+
//
|
|
3
|
+
// One long-lived process, spawned by lib/desktop/manager.js and compiled on the
|
|
4
|
+
// user's machine by lib/desktop/build.js:
|
|
5
|
+
// xcrun swiftc -O -swift-version 5 -target <arch>-apple-macos14.0 -o <bin> SynabunDesktop.swift
|
|
6
|
+
//
|
|
7
|
+
// Protocol: JSON lines over stdio (UTF-8, one object per line). stdout carries
|
|
8
|
+
// nothing else — diagnostics go to stderr or to `log` events.
|
|
9
|
+
// request {"id":17,"cmd":"click","args":{...}}
|
|
10
|
+
// reply {"id":17,"ok":true,"result":{...}}
|
|
11
|
+
// {"id":17,"ok":false,"error":{"code":"SECURE_FIELD","message":"...","details":{...}}}
|
|
12
|
+
// event {"event":"ready"|"user_input"|"emergency_stop"|"screen_lock"|
|
|
13
|
+
// "displays_changed"|"permissions_changed"|"log", ...} (no id)
|
|
14
|
+
//
|
|
15
|
+
// Actions run strictly serially in arrival order on one queue. abort, panic,
|
|
16
|
+
// permissions, session_state, cursor and shutdown bypass that queue and are
|
|
17
|
+
// answered from the stdin thread immediately.
|
|
18
|
+
//
|
|
19
|
+
// Coordinates: every point received or returned is a Quartz GLOBAL DISPLAY POINT
|
|
20
|
+
// (origin at the top-left of the main display, y grows down; secondary displays
|
|
21
|
+
// may have negative origins). Screenshot pixel space never enters this process
|
|
22
|
+
// except for the fitted image it produces.
|
|
23
|
+
//
|
|
24
|
+
// Safety model (the helper is the last line of defence, so it re-checks
|
|
25
|
+
// everything right before posting events):
|
|
26
|
+
// - no Accessibility → NOT_TRUSTED for input/AX; no Screen Recording →
|
|
27
|
+
// NO_SCREEN_RECORDING for capture. Nothing here ever prompts except
|
|
28
|
+
// `request_permission`, and nothing is posted without the post-event grant.
|
|
29
|
+
// - refuseWhenLocked: a locked (or off-console) session refuses every action.
|
|
30
|
+
// - blocked apps / protected windows are evaluated on the probed target
|
|
31
|
+
// (pointer actions) or the focused element + frontmost window (keyboard).
|
|
32
|
+
// Rules match bundle ids, bundle-id prefixes (web apps), app names and
|
|
33
|
+
// window titles; names and titles are NFC-normalized first, so a title in
|
|
34
|
+
// decomposed form still matches a localized pattern.
|
|
35
|
+
// - ax_action press may carry `verify` (protocol 2): the element is re-read
|
|
36
|
+
// right before AXPerformAction and nothing is pressed (TARGET_CHANGED) unless
|
|
37
|
+
// it is still the same, enabled, pressable control in the app's frontmost,
|
|
38
|
+
// focused, sheet-free window.
|
|
39
|
+
// - typing / printable keys / paste / AX set_value never target an
|
|
40
|
+
// AXSecureTextField, and fail closed when focus is unreadable while secure
|
|
41
|
+
// event input is on. The value of a secure field is never read.
|
|
42
|
+
// - every synthetic event carries kCGEventSourceUserData = "SYNB" so the
|
|
43
|
+
// global monitor can tell the user's input from ours. While the monitor is
|
|
44
|
+
// armed, an unstamped Escape or a flick into the top-left corner of the main
|
|
45
|
+
// display emits `emergency_stop`, aborts the in-flight action, releases held
|
|
46
|
+
// input and LATCHES: input/AX/app actions then fail with INTERRUPTED
|
|
47
|
+
// (details.latched) until the next `configure`.
|
|
48
|
+
//
|
|
49
|
+
// Lifetime: exits (after releasing anything it holds) on `shutdown`, stdin EOF,
|
|
50
|
+
// SIGTERM/SIGHUP, or when its parent dies (getppid() == 1, polled every 2 s).
|
|
51
|
+
//
|
|
52
|
+
// Flags: --version prints {"version","protocol"}; --self-test runs the pure
|
|
53
|
+
// checks (key combos, fit math, scroll signs, text chunking, guard matching,
|
|
54
|
+
// JSON round-trip) with no screen access and exits 0/1.
|
|
55
|
+
|
|
56
|
+
import AppKit
|
|
57
|
+
import ApplicationServices
|
|
58
|
+
import Carbon
|
|
59
|
+
import CoreGraphics
|
|
60
|
+
import Darwin
|
|
61
|
+
import Foundation
|
|
62
|
+
import ImageIO
|
|
63
|
+
import IOKit.pwr_mgt
|
|
64
|
+
import ScreenCaptureKit
|
|
65
|
+
|
|
66
|
+
// MARK: - Constants
|
|
67
|
+
|
|
68
|
+
let HELPER_VERSION = "1.1.0"
|
|
69
|
+
let PROTOCOL_VERSION = 2
|
|
70
|
+
/// "SYNB" — stamped into kCGEventSourceUserData of every event this process posts.
|
|
71
|
+
let SYNB_STAMP: Int64 = 0x53594E42
|
|
72
|
+
|
|
73
|
+
#if arch(arm64)
|
|
74
|
+
let HELPER_ARCH = "arm64"
|
|
75
|
+
#elseif arch(x86_64)
|
|
76
|
+
let HELPER_ARCH = "x64"
|
|
77
|
+
#else
|
|
78
|
+
let HELPER_ARCH = "unknown"
|
|
79
|
+
#endif
|
|
80
|
+
|
|
81
|
+
// MARK: - Errors
|
|
82
|
+
|
|
83
|
+
struct HelperError: Error {
|
|
84
|
+
let code: String
|
|
85
|
+
let message: String
|
|
86
|
+
let details: [String: Any]?
|
|
87
|
+
init(_ code: String, _ message: String, _ details: [String: Any]? = nil) {
|
|
88
|
+
self.code = code
|
|
89
|
+
self.message = message
|
|
90
|
+
self.details = details
|
|
91
|
+
}
|
|
92
|
+
func adding(_ extra: [String: Any]) -> HelperError {
|
|
93
|
+
var d = details ?? [:]
|
|
94
|
+
for (k, v) in extra { d[k] = v }
|
|
95
|
+
return HelperError(code, message, d)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: - JSON output
|
|
100
|
+
|
|
101
|
+
/// Anything → a JSONSerialization-safe value (optionals unwrapped, NaN/Inf → null,
|
|
102
|
+
/// CG geometry flattened). JSONSerialization raises an uncatchable ObjC exception
|
|
103
|
+
/// on invalid input, so every outgoing object goes through here.
|
|
104
|
+
func jsonSafe(_ value: Any?) -> Any {
|
|
105
|
+
guard let value = value else { return NSNull() }
|
|
106
|
+
let mirror = Mirror(reflecting: value)
|
|
107
|
+
if mirror.displayStyle == .optional {
|
|
108
|
+
guard let inner = mirror.children.first?.value else { return NSNull() }
|
|
109
|
+
return jsonSafe(inner)
|
|
110
|
+
}
|
|
111
|
+
switch value {
|
|
112
|
+
case is NSNull: return value
|
|
113
|
+
case let s as String: return s
|
|
114
|
+
case let n as NSNumber: return n.doubleValue.isFinite ? n : NSNull()
|
|
115
|
+
case let d as [String: Any]: return d.mapValues { jsonSafe($0) }
|
|
116
|
+
case let a as [Any]: return a.map { jsonSafe($0) }
|
|
117
|
+
case let p as CGPoint: return pointJSON(p)
|
|
118
|
+
case let r as CGRect: return rectJSON(r)
|
|
119
|
+
default: return String(describing: value)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func encodeJSON(_ obj: [String: Any]) -> Data? {
|
|
124
|
+
let safe = jsonSafe(obj)
|
|
125
|
+
guard JSONSerialization.isValidJSONObject(safe) else { return nil }
|
|
126
|
+
return try? JSONSerialization.data(withJSONObject: safe, options: [.withoutEscapingSlashes])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func stderrLine(_ s: String) {
|
|
130
|
+
FileHandle.standardError.write((s + "\n").data(using: .utf8) ?? Data())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
enum Out {
|
|
134
|
+
private static let lock = NSLock()
|
|
135
|
+
private static var broken = false
|
|
136
|
+
|
|
137
|
+
/// One JSON object per line, written with write(2) under a lock so replies
|
|
138
|
+
/// from the stdin thread, the action queue and the main thread never interleave.
|
|
139
|
+
static func send(_ obj: [String: Any]) {
|
|
140
|
+
guard var data = encodeJSON(obj) else {
|
|
141
|
+
stderrLine("synabun-desktop: dropped an unserializable message")
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
data.append(0x0A)
|
|
145
|
+
lock.lock()
|
|
146
|
+
defer { lock.unlock() }
|
|
147
|
+
if broken { return }
|
|
148
|
+
let ok = data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
|
149
|
+
guard var p = raw.baseAddress else { return true }
|
|
150
|
+
var left = raw.count
|
|
151
|
+
while left > 0 {
|
|
152
|
+
let n = Darwin.write(STDOUT_FILENO, p, left)
|
|
153
|
+
if n < 0 {
|
|
154
|
+
if errno == EINTR { continue }
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
p = p.advanced(by: n)
|
|
158
|
+
left -= n
|
|
159
|
+
}
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
if !ok {
|
|
163
|
+
broken = true
|
|
164
|
+
DispatchQueue.global().async { Lifecycle.exit(reason: "stdout closed") }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func emitEvent(_ name: String, _ fields: [String: Any] = [:]) {
|
|
170
|
+
var obj = fields
|
|
171
|
+
obj["event"] = name
|
|
172
|
+
Out.send(obj)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func hlog(_ level: String, _ message: String) {
|
|
176
|
+
emitEvent("log", ["level": level, "message": message])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func reply(_ id: Any?, result: Any) {
|
|
180
|
+
Out.send(["id": id ?? NSNull(), "ok": true, "result": result])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func replyError(_ id: Any?, _ e: HelperError) {
|
|
184
|
+
var err: [String: Any] = ["code": e.code, "message": e.message]
|
|
185
|
+
if let d = e.details { err["details"] = d }
|
|
186
|
+
Out.send(["id": id ?? NSNull(), "ok": false, "error": err])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Small utilities
|
|
190
|
+
|
|
191
|
+
func rectJSON(_ r: CGRect) -> [String: Any] {
|
|
192
|
+
["x": Double(r.origin.x), "y": Double(r.origin.y), "w": Double(r.size.width), "h": Double(r.size.height)]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func pointJSON(_ p: CGPoint) -> [String: Any] { ["x": Double(p.x), "y": Double(p.y)] }
|
|
196
|
+
|
|
197
|
+
func nowNs() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
|
|
198
|
+
|
|
199
|
+
func epochMs() -> Double { (Date().timeIntervalSince1970 * 1000).rounded() }
|
|
200
|
+
|
|
201
|
+
func sleepMs(_ ms: Double) {
|
|
202
|
+
guard ms > 0 else { return }
|
|
203
|
+
usleep(useconds_t(min(ms, 60_000) * 1000))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func clampInt(_ v: Int, _ lo: Int, _ hi: Int) -> Int { max(lo, min(hi, v)) }
|
|
207
|
+
|
|
208
|
+
func clampDouble(_ v: Double, _ lo: Double, _ hi: Double) -> Double { max(lo, min(hi, v)) }
|
|
209
|
+
|
|
210
|
+
/// JSON number → Int without trapping on absurd values (Int(Double) traps out of range).
|
|
211
|
+
func toInt(_ d: Double) -> Int { d.isFinite ? Int(clampDouble(d.rounded(), -1e15, 1e15)) : 0 }
|
|
212
|
+
|
|
213
|
+
/// AppKit and the Text Input Sources API want the main thread (TSM asserts on it
|
|
214
|
+
/// since macOS 14). The main thread only runs the run loop, so a sync hop is safe
|
|
215
|
+
/// from the stdin thread and the action queue.
|
|
216
|
+
func onMain<T>(_ body: () throws -> T) rethrows -> T {
|
|
217
|
+
if Thread.isMainThread { return try body() }
|
|
218
|
+
return try DispatchQueue.main.sync(execute: body)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
final class Box<T> {
|
|
222
|
+
var value: T?
|
|
223
|
+
var error: Error?
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Bridges a completion-handler API to the calling (non-main) thread.
|
|
227
|
+
func waitFor<T>(_ what: String, timeout: Double, _ start: (@escaping (T?, Error?) -> Void) -> Void) throws -> T {
|
|
228
|
+
let box = Box<T>()
|
|
229
|
+
let sem = DispatchSemaphore(value: 0)
|
|
230
|
+
start { value, error in
|
|
231
|
+
box.value = value
|
|
232
|
+
box.error = error
|
|
233
|
+
sem.signal()
|
|
234
|
+
}
|
|
235
|
+
if sem.wait(timeout: .now() + timeout) == .timedOut {
|
|
236
|
+
throw HelperError("TIMEOUT", "\(what) did not answer within \(Int(timeout)) s")
|
|
237
|
+
}
|
|
238
|
+
if let e = box.error { throw e }
|
|
239
|
+
guard let v = box.value else { throw HelperError("INTERNAL", "\(what) returned nothing") }
|
|
240
|
+
return v
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func trimmed(_ s: String?, _ limit: Int) -> String? {
|
|
244
|
+
guard let s = s else { return nil }
|
|
245
|
+
return s.count > limit ? String(s.prefix(limit)) : s
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MARK: - Arguments
|
|
249
|
+
|
|
250
|
+
struct Args {
|
|
251
|
+
let raw: [String: Any]
|
|
252
|
+
init(_ raw: [String: Any]) { self.raw = raw }
|
|
253
|
+
|
|
254
|
+
static func num(_ v: Any?) -> Double? {
|
|
255
|
+
guard let n = v as? NSNumber, CFGetTypeID(n as CFTypeRef) != CFBooleanGetTypeID() else { return nil }
|
|
256
|
+
let d = n.doubleValue
|
|
257
|
+
return d.isFinite ? d : nil
|
|
258
|
+
}
|
|
259
|
+
static func bool(_ v: Any?) -> Bool? { (v as? NSNumber)?.boolValue }
|
|
260
|
+
|
|
261
|
+
func has(_ k: String) -> Bool { raw[k] != nil && !(raw[k] is NSNull) }
|
|
262
|
+
func num(_ k: String) -> Double? { Args.num(raw[k]) }
|
|
263
|
+
func int(_ k: String) -> Int? { num(k).map(toInt) }
|
|
264
|
+
func string(_ k: String) -> String? { raw[k] as? String }
|
|
265
|
+
func bool(_ k: String) -> Bool? { Args.bool(raw[k]) }
|
|
266
|
+
func dict(_ k: String) -> [String: Any]? { raw[k] as? [String: Any] }
|
|
267
|
+
func array(_ k: String) -> [Any]? { raw[k] as? [Any] }
|
|
268
|
+
|
|
269
|
+
static func point(_ d: [String: Any]?, _ name: String) throws -> CGPoint {
|
|
270
|
+
guard let d = d, let x = num(d["x"]), let y = num(d["y"]) else {
|
|
271
|
+
throw HelperError("BAD_ARGS", "\(name) needs numeric x and y (global display points)")
|
|
272
|
+
}
|
|
273
|
+
return CGPoint(x: x, y: y)
|
|
274
|
+
}
|
|
275
|
+
func point() throws -> CGPoint { try Args.point(raw, "this command") }
|
|
276
|
+
|
|
277
|
+
static func rect(_ d: [String: Any]?, _ name: String) throws -> CGRect {
|
|
278
|
+
guard let d = d, let x = num(d["x"]), let y = num(d["y"]), let w = num(d["w"]), let h = num(d["h"]), w > 0, h > 0 else {
|
|
279
|
+
throw HelperError("BAD_ARGS", "\(name) needs numeric x, y and positive w, h")
|
|
280
|
+
}
|
|
281
|
+
return CGRect(x: x, y: y, width: w, height: h)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// MARK: - Pure logic (covered by --self-test)
|
|
286
|
+
|
|
287
|
+
struct FitResult {
|
|
288
|
+
let w: Int
|
|
289
|
+
let h: Int
|
|
290
|
+
let factor: Double
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Output size for a W×H point area on a display with backing scale s, fitted
|
|
294
|
+
/// into fit.w × fit.h: f = min(fit.w/W, fit.h/H, s); image = round(W·f) × round(H·f).
|
|
295
|
+
/// Never upscales past native pixels.
|
|
296
|
+
func fitSize(width W: Double, height H: Double, fitW: Double, fitH: Double, scale s: Double) -> FitResult {
|
|
297
|
+
guard W > 0, H > 0 else { return FitResult(w: 0, h: 0, factor: 0) }
|
|
298
|
+
let fw = fitW > 0 ? fitW : W * s
|
|
299
|
+
let fh = fitH > 0 ? fitH : H * s
|
|
300
|
+
let f = max(1e-6, min(fw / W, fh / H, s > 0 ? s : 1))
|
|
301
|
+
return FitResult(w: max(1, Int((W * f).rounded())), h: max(1, Int((H * f).rounded())), factor: f)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/// Protocol: dy > 0 scrolls DOWN (toward the end of the document), dx > 0 scrolls
|
|
305
|
+
/// RIGHT. Quartz scroll-wheel events are the other way round: wheel1 (vertical)
|
|
306
|
+
/// is positive for UP and wheel2 (horizontal) is positive for LEFT. So both signs
|
|
307
|
+
/// flip. Synthetic deltas are not affected by the "natural scrolling" setting.
|
|
308
|
+
func scrollWheelDeltas(dx: Int, dy: Int) -> (wheel1: Int32, wheel2: Int32) {
|
|
309
|
+
(Int32(clamping: -clampInt(dy, -1_000_000, 1_000_000)), Int32(clamping: -clampInt(dx, -1_000_000, 1_000_000)))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
enum Mod: String, CaseIterable {
|
|
313
|
+
case ctrl, alt, shift, cmd, fn
|
|
314
|
+
|
|
315
|
+
var flag: CGEventFlags {
|
|
316
|
+
switch self {
|
|
317
|
+
case .cmd: return .maskCommand
|
|
318
|
+
case .shift: return .maskShift
|
|
319
|
+
case .alt: return .maskAlternate
|
|
320
|
+
case .ctrl: return .maskControl
|
|
321
|
+
case .fn: return .maskSecondaryFn
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/// kVK_Command, kVK_Shift, kVK_Option, kVK_Control, kVK_Function
|
|
325
|
+
var keyCode: CGKeyCode {
|
|
326
|
+
switch self {
|
|
327
|
+
case .cmd: return 0x37
|
|
328
|
+
case .shift: return 0x38
|
|
329
|
+
case .alt: return 0x3A
|
|
330
|
+
case .ctrl: return 0x3B
|
|
331
|
+
case .fn: return 0x3F
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/// Right-hand twins released by `panic` (kVK_RightCommand/Shift/Option/Control).
|
|
335
|
+
var rightKeyCode: CGKeyCode? {
|
|
336
|
+
switch self {
|
|
337
|
+
case .cmd: return 0x36
|
|
338
|
+
case .shift: return 0x3C
|
|
339
|
+
case .alt: return 0x3D
|
|
340
|
+
case .ctrl: return 0x3E
|
|
341
|
+
case .fn: return nil
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
static func parse(_ s: String) -> Mod? {
|
|
345
|
+
switch s.lowercased() {
|
|
346
|
+
case "cmd", "command", "super": return .cmd
|
|
347
|
+
case "shift": return .shift
|
|
348
|
+
case "alt", "option", "opt": return .alt
|
|
349
|
+
case "ctrl", "control": return .ctrl
|
|
350
|
+
case "fn", "function": return .fn
|
|
351
|
+
default: return nil
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// xdotool-style key names → kVK_* virtual key codes (layout independent keys).
|
|
357
|
+
let NAMED_KEYS: [String: CGKeyCode] = [
|
|
358
|
+
"return": 0x24, "enter": 0x24, "kp_enter": 0x4C, "tab": 0x30, "escape": 0x35, "esc": 0x35,
|
|
359
|
+
"backspace": 0x33, "delete": 0x75, "forwarddelete": 0x75,
|
|
360
|
+
"up": 0x7E, "down": 0x7D, "left": 0x7B, "right": 0x7C,
|
|
361
|
+
"home": 0x73, "end": 0x77, "page_up": 0x74, "pageup": 0x74, "prior": 0x74,
|
|
362
|
+
"page_down": 0x79, "pagedown": 0x79, "next": 0x79, "help": 0x72, "insert": 0x72,
|
|
363
|
+
"f1": 0x7A, "f2": 0x78, "f3": 0x63, "f4": 0x76, "f5": 0x60, "f6": 0x61, "f7": 0x62,
|
|
364
|
+
"f8": 0x64, "f9": 0x65, "f10": 0x6D, "f11": 0x67, "f12": 0x6F, "f13": 0x69, "f14": 0x6B,
|
|
365
|
+
"f15": 0x71, "f16": 0x6A, "f17": 0x40, "f18": 0x4F, "f19": 0x50, "f20": 0x5A,
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
/// xdotool keysym names that stand for a character.
|
|
369
|
+
let KEYSYM_CHARS: [String: Character] = [
|
|
370
|
+
"space": " ", "minus": "-", "equal": "=", "plus": "+", "underscore": "_", "comma": ",",
|
|
371
|
+
"period": ".", "slash": "/", "backslash": "\\", "semicolon": ";", "colon": ":",
|
|
372
|
+
"apostrophe": "'", "quoteright": "'", "quotedbl": "\"", "grave": "`", "quoteleft": "`",
|
|
373
|
+
"asciitilde": "~", "bracketleft": "[", "bracketright": "]", "braceleft": "{", "braceright": "}",
|
|
374
|
+
"parenleft": "(", "parenright": ")", "less": "<", "greater": ">", "question": "?",
|
|
375
|
+
"exclam": "!", "at": "@", "numbersign": "#", "dollar": "$", "percent": "%",
|
|
376
|
+
"asciicircum": "^", "ampersand": "&", "asterisk": "*", "bar": "|",
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
/// US ANSI positions — the fallback when the current layout can't produce a character
|
|
380
|
+
/// (shortcuts on non-Latin layouts resolve through the ANSI position, like macOS does).
|
|
381
|
+
let ANSI_KEYS: [Character: CGKeyCode] = [
|
|
382
|
+
"a": 0x00, "s": 0x01, "d": 0x02, "f": 0x03, "h": 0x04, "g": 0x05, "z": 0x06, "x": 0x07,
|
|
383
|
+
"c": 0x08, "v": 0x09, "b": 0x0B, "q": 0x0C, "w": 0x0D, "e": 0x0E, "r": 0x0F, "y": 0x10,
|
|
384
|
+
"t": 0x11, "1": 0x12, "2": 0x13, "3": 0x14, "4": 0x15, "6": 0x16, "5": 0x17, "=": 0x18,
|
|
385
|
+
"9": 0x19, "7": 0x1A, "-": 0x1B, "8": 0x1C, "0": 0x1D, "]": 0x1E, "o": 0x1F, "u": 0x20,
|
|
386
|
+
"[": 0x21, "i": 0x22, "p": 0x23, "l": 0x25, "j": 0x26, "'": 0x27, "k": 0x28, ";": 0x29,
|
|
387
|
+
"\\": 0x2A, ",": 0x2B, "/": 0x2C, "n": 0x2D, "m": 0x2E, ".": 0x2F, "`": 0x32, " ": 0x31,
|
|
388
|
+
]
|
|
389
|
+
let ANSI_SHIFTED: [Character: Character] = [
|
|
390
|
+
"!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9",
|
|
391
|
+
")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", ":": ";", "\"": "'", "<": ",",
|
|
392
|
+
">": ".", "?": "/", "~": "`",
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
/// Character → (virtual key, UCKeyTranslate modifier state: 0 none, 2 shift, 8 option, 10 both).
|
|
396
|
+
typealias LayoutMap = [String: (code: CGKeyCode, state: UInt32)]
|
|
397
|
+
|
|
398
|
+
struct ResolvedChar {
|
|
399
|
+
let code: CGKeyCode
|
|
400
|
+
let shift: Bool
|
|
401
|
+
let alt: Bool
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
func resolveChar(_ ch: Character, layout: LayoutMap?, ansiFallback: Bool) -> ResolvedChar? {
|
|
405
|
+
let s = String(ch)
|
|
406
|
+
if let layout = layout {
|
|
407
|
+
if let m = layout[s] { return ResolvedChar(code: m.code, shift: m.state & 2 != 0, alt: m.state & 8 != 0) }
|
|
408
|
+
let lower = s.lowercased()
|
|
409
|
+
if lower != s, let m = layout[lower], m.state == 0 { return ResolvedChar(code: m.code, shift: true, alt: false) }
|
|
410
|
+
}
|
|
411
|
+
guard ansiFallback || layout == nil || layout!.isEmpty else { return nil }
|
|
412
|
+
let lower = s.lowercased()
|
|
413
|
+
if lower.count == 1, let c = ANSI_KEYS[Character(lower)] { return ResolvedChar(code: c, shift: lower != s, alt: false) }
|
|
414
|
+
if let base = ANSI_SHIFTED[ch], let c = ANSI_KEYS[base] { return ResolvedChar(code: c, shift: true, alt: false) }
|
|
415
|
+
return nil
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
struct KeyCombo {
|
|
419
|
+
var mods: [Mod] = []
|
|
420
|
+
var keyCode: CGKeyCode? = nil
|
|
421
|
+
var keyName = ""
|
|
422
|
+
/// The key produces a character (letters, digits, punctuation, space).
|
|
423
|
+
var isCharKey = false
|
|
424
|
+
|
|
425
|
+
var flags: CGEventFlags { mods.reduce(CGEventFlags()) { $0.union($1.flag) } }
|
|
426
|
+
/// Printable input (no modifiers other than shift/option) or a paste (cmd+v):
|
|
427
|
+
/// these must never land in a secure field.
|
|
428
|
+
var needsSecureCheck: Bool {
|
|
429
|
+
guard keyCode != nil else { return false }
|
|
430
|
+
if isCharKey && mods.allSatisfy({ $0 == .shift || $0 == .alt }) { return true }
|
|
431
|
+
if keyName == "v" && mods.contains(.cmd) { return true }
|
|
432
|
+
return false
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/// "cmd+shift+t", "Return", "ctrl+alt+Delete", "cmd++", "shift" (modifier only).
|
|
437
|
+
/// An uppercase single letter implies shift ("cmd+T" == "cmd+shift+t").
|
|
438
|
+
func parseCombo(_ raw: String, layout: LayoutMap?) throws -> KeyCombo {
|
|
439
|
+
let s = raw.trimmingCharacters(in: .whitespaces)
|
|
440
|
+
guard !s.isEmpty else { throw HelperError("BAD_ARGS", "empty key combo") }
|
|
441
|
+
var tokens: [String]
|
|
442
|
+
if s == "+" {
|
|
443
|
+
tokens = ["+"]
|
|
444
|
+
} else if s.hasSuffix("++") {
|
|
445
|
+
tokens = s.dropLast(2).split(separator: "+", omittingEmptySubsequences: false).map(String.init) + ["+"]
|
|
446
|
+
} else {
|
|
447
|
+
tokens = s.split(separator: "+", omittingEmptySubsequences: false).map(String.init)
|
|
448
|
+
}
|
|
449
|
+
tokens = tokens.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
450
|
+
if tokens.contains(where: { $0.isEmpty }) { throw HelperError("BAD_ARGS", "malformed key combo '\(raw)'") }
|
|
451
|
+
var combo = KeyCombo()
|
|
452
|
+
for (i, token) in tokens.enumerated() {
|
|
453
|
+
let last = i == tokens.count - 1
|
|
454
|
+
if let m = Mod.parse(token) {
|
|
455
|
+
if !combo.mods.contains(m) { combo.mods.append(m) }
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
guard last else { throw HelperError("BAD_ARGS", "unknown modifier '\(token)' in '\(raw)'") }
|
|
459
|
+
let lower = token.lowercased()
|
|
460
|
+
if let code = NAMED_KEYS[lower] {
|
|
461
|
+
combo.keyCode = code
|
|
462
|
+
combo.keyName = lower
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
var ch: Character? = nil
|
|
466
|
+
if let c = KEYSYM_CHARS[lower] { ch = c } else if token.count == 1 { ch = token.first }
|
|
467
|
+
guard let c = ch else { throw HelperError("BAD_ARGS", "unknown key '\(token)' in '\(raw)'") }
|
|
468
|
+
guard let r = resolveChar(c, layout: layout, ansiFallback: true) else {
|
|
469
|
+
throw HelperError("BAD_ARGS", "no key produces '\(c)' on the current keyboard layout")
|
|
470
|
+
}
|
|
471
|
+
combo.keyCode = r.code
|
|
472
|
+
combo.isCharKey = true
|
|
473
|
+
combo.keyName = String(c).lowercased()
|
|
474
|
+
if r.shift && !combo.mods.contains(.shift) { combo.mods.append(.shift) }
|
|
475
|
+
if r.alt && !combo.mods.contains(.alt) { combo.mods.append(.alt) }
|
|
476
|
+
}
|
|
477
|
+
return combo
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
enum TypeSegment {
|
|
481
|
+
case text(String)
|
|
482
|
+
case key(CGKeyCode, String)
|
|
483
|
+
|
|
484
|
+
var utf16Count: Int {
|
|
485
|
+
switch self {
|
|
486
|
+
case .text(let s): return s.utf16.count
|
|
487
|
+
case .key(_, let s): return s.utf16.count
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/// Newlines become Return and tabs become Tab key presses; everything else is
|
|
493
|
+
/// grouped into chunks of at most `chunk` (≤ 20) UTF-16 units without splitting a
|
|
494
|
+
/// grapheme cluster.
|
|
495
|
+
func segmentText(_ text: String, chunk: Int) -> [TypeSegment] {
|
|
496
|
+
let limit = clampInt(chunk, 1, 20)
|
|
497
|
+
var out: [TypeSegment] = []
|
|
498
|
+
var buf = ""
|
|
499
|
+
var units = 0
|
|
500
|
+
func flush() {
|
|
501
|
+
if !buf.isEmpty { out.append(.text(buf)) }
|
|
502
|
+
buf = ""
|
|
503
|
+
units = 0
|
|
504
|
+
}
|
|
505
|
+
for ch in text {
|
|
506
|
+
if ch == "\n" || ch == "\r\n" || ch == "\r" {
|
|
507
|
+
flush()
|
|
508
|
+
out.append(.key(0x24, String(ch)))
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
if ch == "\t" {
|
|
512
|
+
flush()
|
|
513
|
+
out.append(.key(0x30, "\t"))
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
let n = String(ch).utf16.count
|
|
517
|
+
if units + n > limit { flush() }
|
|
518
|
+
buf.append(ch)
|
|
519
|
+
units += n
|
|
520
|
+
}
|
|
521
|
+
flush()
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// MARK: - Configuration
|
|
526
|
+
|
|
527
|
+
struct BlockRule {
|
|
528
|
+
let id: String
|
|
529
|
+
let bundleIds: [String]
|
|
530
|
+
/// Dotted bundle-id prefixes ending in "." (e.g. "com.apple.Safari.WebApp.").
|
|
531
|
+
var bundlePrefixes: [String] = []
|
|
532
|
+
let nameRe: NSRegularExpression?
|
|
533
|
+
let windowTitleRe: NSRegularExpression?
|
|
534
|
+
let reason: String
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
struct ProtectRule {
|
|
538
|
+
let index: Int
|
|
539
|
+
var id: String? = nil
|
|
540
|
+
let bundleIds: [String]
|
|
541
|
+
var bundlePrefixes: [String] = []
|
|
542
|
+
/// At least one of titleRe / appNameRe is set (parseGuard refuses neither).
|
|
543
|
+
let titleRe: NSRegularExpression?
|
|
544
|
+
var appNameRe: NSRegularExpression? = nil
|
|
545
|
+
let reason: String
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
struct GuardConfig {
|
|
549
|
+
var blocked: [BlockRule] = []
|
|
550
|
+
var protectedWindows: [ProtectRule] = []
|
|
551
|
+
var secureField = true
|
|
552
|
+
var refuseWhenLocked = true
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
struct MonitorConfig {
|
|
556
|
+
var armed = false
|
|
557
|
+
var esc = true
|
|
558
|
+
var failsafeCorner = true
|
|
559
|
+
var cornerSizePt = 4.0
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
struct InputConfig {
|
|
563
|
+
var typeChunk = 20
|
|
564
|
+
var typeDelayMs = 10.0
|
|
565
|
+
var moveSteps = 8
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
enum Config {
|
|
569
|
+
private static let lock = NSLock()
|
|
570
|
+
private static var _guard = GuardConfig()
|
|
571
|
+
private static var _monitor = MonitorConfig()
|
|
572
|
+
private static var _input = InputConfig()
|
|
573
|
+
|
|
574
|
+
static var guardCfg: GuardConfig { lock.lock(); defer { lock.unlock() }; return _guard }
|
|
575
|
+
static var monitor: MonitorConfig { lock.lock(); defer { lock.unlock() }; return _monitor }
|
|
576
|
+
static var input: InputConfig { lock.lock(); defer { lock.unlock() }; return _input }
|
|
577
|
+
|
|
578
|
+
static func commit(_ g: GuardConfig, _ m: MonitorConfig, _ i: InputConfig) {
|
|
579
|
+
lock.lock()
|
|
580
|
+
_guard = g
|
|
581
|
+
_monitor = m
|
|
582
|
+
_input = i
|
|
583
|
+
lock.unlock()
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/// Titles, app names and patterns are compared in NFC: System Settings and web
|
|
588
|
+
/// pages may hand over "Segurança" decomposed (c + U+0327), which ICU would not
|
|
589
|
+
/// match against the precomposed pattern.
|
|
590
|
+
func nfc(_ s: String) -> String { s.precomposedStringWithCanonicalMapping }
|
|
591
|
+
|
|
592
|
+
/// Guard patterns are compiled case-insensitively (ICU syntax — plain JS-style
|
|
593
|
+
/// alternations and classes behave the same). An empty pattern means "absent".
|
|
594
|
+
func compileRegex(_ pattern: Any?, field: String) throws -> NSRegularExpression? {
|
|
595
|
+
guard let p = pattern as? String, !p.isEmpty else { return nil }
|
|
596
|
+
do {
|
|
597
|
+
return try NSRegularExpression(pattern: nfc(p), options: [.caseInsensitive])
|
|
598
|
+
} catch {
|
|
599
|
+
throw HelperError("BAD_ARGS", "\(field) is not a valid regular expression", ["field": field, "pattern": p])
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
func reMatch(_ re: NSRegularExpression, _ s: String) -> Bool {
|
|
604
|
+
re.firstMatch(in: s, options: [], range: NSRange(s.startIndex..<s.endIndex, in: s)) != nil
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/// Same rule as protocol.js BUNDLE_PREFIX_RE: dotted reverse-DNS, at least two
|
|
608
|
+
/// segments, trailing dot — so a prefix can never equal a bare bundle id.
|
|
609
|
+
let BUNDLE_PREFIX_PATTERN = "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+\\.$"
|
|
610
|
+
|
|
611
|
+
func parsePrefixes(_ v: Any?, field: String) throws -> [String] {
|
|
612
|
+
guard let v = v, !(v is NSNull) else { return [] }
|
|
613
|
+
guard let arr = v as? [Any] else { throw HelperError("BAD_ARGS", "\(field) must be an array", ["field": field]) }
|
|
614
|
+
return try arr.enumerated().map { (i, item) -> String in
|
|
615
|
+
guard let s = item as? String, s.range(of: BUNDLE_PREFIX_PATTERN, options: .regularExpression) != nil else {
|
|
616
|
+
throw HelperError("BAD_ARGS", "\(field)[\(i)] must be a dotted bundle id prefix ending in \".\"", ["field": "\(field)[\(i)]"])
|
|
617
|
+
}
|
|
618
|
+
return s
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
func hasBundlePrefix(_ bundleId: String, _ prefixes: [String]) -> Bool {
|
|
623
|
+
let b = bundleId.lowercased()
|
|
624
|
+
return prefixes.contains { !$0.isEmpty && b.hasPrefix($0.lowercased()) }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
func parseGuard(_ d: [String: Any], into g: inout GuardConfig) throws {
|
|
628
|
+
if let arr = d["blockedApps"] as? [Any] {
|
|
629
|
+
g.blocked = try arr.enumerated().map { (i, item) -> BlockRule in
|
|
630
|
+
let field = "guard.blockedApps[\(i)]"
|
|
631
|
+
guard let r = item as? [String: Any] else { throw HelperError("BAD_ARGS", "\(field) must be an object") }
|
|
632
|
+
return BlockRule(
|
|
633
|
+
id: (r["id"] as? String) ?? "rule\(i)",
|
|
634
|
+
bundleIds: (r["bundleIds"] as? [Any])?.compactMap { $0 as? String } ?? [],
|
|
635
|
+
bundlePrefixes: try parsePrefixes(r["bundlePrefixes"], field: "\(field).bundlePrefixes"),
|
|
636
|
+
nameRe: try compileRegex(r["nameRe"], field: "\(field).nameRe"),
|
|
637
|
+
windowTitleRe: try compileRegex(r["windowTitleRe"], field: "\(field).windowTitleRe"),
|
|
638
|
+
reason: (r["reason"] as? String) ?? "")
|
|
639
|
+
}
|
|
640
|
+
} else if d["blockedApps"] != nil && !(d["blockedApps"] is NSNull) {
|
|
641
|
+
throw HelperError("BAD_ARGS", "guard.blockedApps must be an array")
|
|
642
|
+
}
|
|
643
|
+
if let arr = d["protectedWindows"] as? [Any] {
|
|
644
|
+
g.protectedWindows = try arr.enumerated().map { (i, item) -> ProtectRule in
|
|
645
|
+
let field = "guard.protectedWindows[\(i)]"
|
|
646
|
+
guard let r = item as? [String: Any] else { throw HelperError("BAD_ARGS", "\(field) must be an object") }
|
|
647
|
+
let titleRe = try compileRegex(r["titleRe"], field: "\(field).titleRe")
|
|
648
|
+
let appNameRe = try compileRegex(r["appNameRe"], field: "\(field).appNameRe")
|
|
649
|
+
guard titleRe != nil || appNameRe != nil else {
|
|
650
|
+
throw HelperError("BAD_ARGS", "\(field).titleRe (or appNameRe) is required")
|
|
651
|
+
}
|
|
652
|
+
return ProtectRule(
|
|
653
|
+
index: i,
|
|
654
|
+
id: r["id"] as? String,
|
|
655
|
+
bundleIds: (r["bundleIds"] as? [Any])?.compactMap { $0 as? String } ?? [],
|
|
656
|
+
bundlePrefixes: try parsePrefixes(r["bundlePrefixes"], field: "\(field).bundlePrefixes"),
|
|
657
|
+
titleRe: titleRe,
|
|
658
|
+
appNameRe: appNameRe,
|
|
659
|
+
reason: (r["reason"] as? String) ?? "")
|
|
660
|
+
}
|
|
661
|
+
} else if d["protectedWindows"] != nil && !(d["protectedWindows"] is NSNull) {
|
|
662
|
+
throw HelperError("BAD_ARGS", "guard.protectedWindows must be an array")
|
|
663
|
+
}
|
|
664
|
+
if let b = Args.bool(d["secureField"]) { g.secureField = b }
|
|
665
|
+
if let b = Args.bool(d["refuseWhenLocked"]) { g.refuseWhenLocked = b }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// MARK: - Probe (what an action would hit)
|
|
669
|
+
|
|
670
|
+
struct Probe {
|
|
671
|
+
var pid: Int? = nil
|
|
672
|
+
var bundleId: String? = nil
|
|
673
|
+
var app: String? = nil
|
|
674
|
+
var windowId: Int? = nil
|
|
675
|
+
var windowTitle: String? = nil
|
|
676
|
+
var role: String? = nil
|
|
677
|
+
var subrole: String? = nil
|
|
678
|
+
var title: String? = nil
|
|
679
|
+
var desc: String? = nil
|
|
680
|
+
var valuePreview: String? = nil
|
|
681
|
+
var secure = false
|
|
682
|
+
var enabled: Bool? = nil
|
|
683
|
+
var frame: CGRect? = nil
|
|
684
|
+
/// false when the element's attributes could not be read (hung app, no element).
|
|
685
|
+
var readable = true
|
|
686
|
+
var axError: AXError = .success
|
|
687
|
+
|
|
688
|
+
var json: [String: Any] {
|
|
689
|
+
[
|
|
690
|
+
"pid": pid as Any, "bundleId": bundleId as Any, "app": app as Any,
|
|
691
|
+
"windowId": windowId as Any, "windowTitle": windowTitle as Any,
|
|
692
|
+
"role": role as Any, "subrole": subrole as Any, "title": title as Any,
|
|
693
|
+
"description": desc as Any, "valuePreview": (secure ? nil : valuePreview) as Any,
|
|
694
|
+
"secure": secure, "enabled": enabled as Any, "frame": frame.map { rectJSON($0) } as Any,
|
|
695
|
+
]
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
func sameId(_ a: String, _ b: String) -> Bool { a.caseInsensitiveCompare(b) == .orderedSame }
|
|
700
|
+
|
|
701
|
+
/// Blocked app: bundle id listed, OR bundle id starts with a listed prefix, OR
|
|
702
|
+
/// app name matches nameRe, OR window title matches windowTitleRe. Protected
|
|
703
|
+
/// window: the app is in scope — bundle id listed or prefixed; no ids and no
|
|
704
|
+
/// prefixes means any app — AND (window title matches titleRe OR app name
|
|
705
|
+
/// matches appNameRe). Names and titles are NFC-normalized first; unknown titles
|
|
706
|
+
/// and names never match. Same semantics as protocol.js matchGuard.
|
|
707
|
+
func matchGuard(_ p: Probe, _ g: GuardConfig, context: String) -> HelperError? {
|
|
708
|
+
let appName = p.app.map(nfc)
|
|
709
|
+
let title = p.windowTitle.map(nfc)
|
|
710
|
+
for r in g.blocked {
|
|
711
|
+
var why: String? = nil
|
|
712
|
+
if let b = p.bundleId, r.bundleIds.contains(where: { sameId($0, b) }) {
|
|
713
|
+
why = "bundleId"
|
|
714
|
+
} else if let b = p.bundleId, hasBundlePrefix(b, r.bundlePrefixes) {
|
|
715
|
+
why = "bundlePrefix"
|
|
716
|
+
} else if let re = r.nameRe, let n = appName, reMatch(re, n) {
|
|
717
|
+
why = "name"
|
|
718
|
+
} else if let re = r.windowTitleRe, let t = title, reMatch(re, t) {
|
|
719
|
+
why = "windowTitle"
|
|
720
|
+
}
|
|
721
|
+
if let why = why {
|
|
722
|
+
let who = p.app ?? p.bundleId ?? "this app"
|
|
723
|
+
return HelperError("BLOCKED_APP", "\(who) is blocked for computer use\(r.reason.isEmpty ? "" : " (\(r.reason))")",
|
|
724
|
+
["probe": p.json, "rule": ["id": r.id, "reason": r.reason, "matched": why], "context": context])
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
for r in g.protectedWindows {
|
|
728
|
+
let scoped = !r.bundleIds.isEmpty || !r.bundlePrefixes.isEmpty
|
|
729
|
+
let appOk = !scoped || (p.bundleId.map { b in r.bundleIds.contains(where: { sameId($0, b) }) || hasBundlePrefix(b, r.bundlePrefixes) } ?? false)
|
|
730
|
+
guard appOk else { continue }
|
|
731
|
+
var why: String? = nil
|
|
732
|
+
if let re = r.titleRe, let t = title, reMatch(re, t) {
|
|
733
|
+
why = "title"
|
|
734
|
+
} else if let re = r.appNameRe, let n = appName, reMatch(re, n) {
|
|
735
|
+
why = "appName"
|
|
736
|
+
}
|
|
737
|
+
if let why = why {
|
|
738
|
+
let what = why == "title" ? "the window \"\(p.windowTitle ?? "")\"" : "\(p.app ?? "this app") (every window)"
|
|
739
|
+
return HelperError("PROTECTED_WINDOW", "\(what) is protected\(r.reason.isEmpty ? "" : " (\(r.reason))")",
|
|
740
|
+
["probe": p.json, "rule": ["index": r.index, "id": r.id as Any, "reason": r.reason, "matched": why], "context": context])
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return nil
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// MARK: - Displays
|
|
747
|
+
|
|
748
|
+
enum Displays {
|
|
749
|
+
static func ids() -> [CGDirectDisplayID] {
|
|
750
|
+
var count: UInt32 = 0
|
|
751
|
+
guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] }
|
|
752
|
+
var list = [CGDirectDisplayID](repeating: 0, count: Int(count))
|
|
753
|
+
guard CGGetActiveDisplayList(count, &list, &count) == .success else { return [] }
|
|
754
|
+
return Array(list.prefix(Int(count)))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
static func scale(_ id: CGDirectDisplayID) -> Double {
|
|
758
|
+
guard let mode = CGDisplayCopyDisplayMode(id), mode.width > 0 else { return 1 }
|
|
759
|
+
return Double(mode.pixelWidth) / Double(mode.width)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
static func pixelSize(_ id: CGDirectDisplayID) -> (w: Int, h: Int) {
|
|
763
|
+
let b = CGDisplayBounds(id)
|
|
764
|
+
guard let mode = CGDisplayCopyDisplayMode(id) else { return (Int(b.width), Int(b.height)) }
|
|
765
|
+
return (mode.pixelWidth, mode.pixelHeight)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
static func names() -> [CGDirectDisplayID: String] {
|
|
769
|
+
onMain {
|
|
770
|
+
var out: [CGDirectDisplayID: String] = [:]
|
|
771
|
+
for screen in NSScreen.screens {
|
|
772
|
+
if let n = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber {
|
|
773
|
+
out[n.uint32Value] = screen.localizedName
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return out
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/// Main display first, then left-to-right / top-to-bottom. Mirrors are skipped.
|
|
781
|
+
static func list() -> [[String: Any]] {
|
|
782
|
+
let names = names()
|
|
783
|
+
let all = ids().filter { CGDisplayMirrorsDisplay($0) == 0 }
|
|
784
|
+
let sorted = all.sorted { a, b in
|
|
785
|
+
let am = CGDisplayIsMain(a) != 0, bm = CGDisplayIsMain(b) != 0
|
|
786
|
+
if am != bm { return am }
|
|
787
|
+
let ra = CGDisplayBounds(a), rb = CGDisplayBounds(b)
|
|
788
|
+
return ra.minX != rb.minX ? ra.minX < rb.minX : ra.minY < rb.minY
|
|
789
|
+
}
|
|
790
|
+
return sorted.enumerated().map { (i, id) -> [String: Any] in
|
|
791
|
+
let px = pixelSize(id)
|
|
792
|
+
return [
|
|
793
|
+
"id": Int(id), "index": i, "main": CGDisplayIsMain(id) != 0,
|
|
794
|
+
"bounds": rectJSON(CGDisplayBounds(id)), "pixel": ["w": px.w, "h": px.h],
|
|
795
|
+
"scale": scale(id), "asleep": CGDisplayIsAsleep(id) != 0, "name": names[id] as Any,
|
|
796
|
+
]
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
static func containing(_ p: CGPoint) -> CGDirectDisplayID? {
|
|
801
|
+
var id: CGDirectDisplayID = 0
|
|
802
|
+
var count: UInt32 = 0
|
|
803
|
+
guard CGGetDisplaysWithPoint(p, 1, &id, &count) == .success, count > 0 else { return nil }
|
|
804
|
+
return id
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// MARK: - Windows (CGWindowList — titles need Screen Recording; never prompts)
|
|
809
|
+
|
|
810
|
+
struct WinInfo {
|
|
811
|
+
let windowId: Int
|
|
812
|
+
let pid: Int
|
|
813
|
+
let owner: String?
|
|
814
|
+
let title: String?
|
|
815
|
+
let bounds: CGRect
|
|
816
|
+
let layer: Int
|
|
817
|
+
let onScreen: Bool
|
|
818
|
+
let alpha: Double
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
enum Windows {
|
|
822
|
+
static func parse(_ raw: [[String: Any]]) -> [WinInfo] {
|
|
823
|
+
raw.compactMap { d in
|
|
824
|
+
guard let num = (d[kCGWindowNumber as String] as? NSNumber)?.intValue,
|
|
825
|
+
let pid = (d[kCGWindowOwnerPID as String] as? NSNumber)?.intValue else { return nil }
|
|
826
|
+
var rect = CGRect.zero
|
|
827
|
+
if let bd = d[kCGWindowBounds as String] as? NSDictionary, let r = CGRect(dictionaryRepresentation: bd as CFDictionary) {
|
|
828
|
+
rect = r
|
|
829
|
+
}
|
|
830
|
+
let title = d[kCGWindowName as String] as? String
|
|
831
|
+
return WinInfo(
|
|
832
|
+
windowId: num, pid: pid, owner: d[kCGWindowOwnerName as String] as? String,
|
|
833
|
+
title: (title?.isEmpty ?? true) ? nil : title, bounds: rect,
|
|
834
|
+
layer: (d[kCGWindowLayer as String] as? NSNumber)?.intValue ?? 0,
|
|
835
|
+
onScreen: (d[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false,
|
|
836
|
+
alpha: (d[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 1)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
static func list(onScreenOnly: Bool) -> [WinInfo] {
|
|
841
|
+
let opts: CGWindowListOption = onScreenOnly ? [.optionOnScreenOnly, .excludeDesktopElements] : [.optionAll, .excludeDesktopElements]
|
|
842
|
+
guard let raw = CGWindowListCopyWindowInfo(opts, 0) as? [[String: Any]] else { return [] }
|
|
843
|
+
return parse(raw)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
static func info(_ windowId: Int) -> WinInfo? {
|
|
847
|
+
guard windowId > 0, windowId <= Int(UInt32.max),
|
|
848
|
+
let raw = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(windowId)) as? [[String: Any]] else { return nil }
|
|
849
|
+
return parse(raw).first { $0.windowId == windowId }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/// Front-most visible window under a point (optionally owned by pid).
|
|
853
|
+
static func at(_ p: CGPoint, pid: Int? = nil) -> WinInfo? {
|
|
854
|
+
list(onScreenOnly: true).first { w in
|
|
855
|
+
w.alpha > 0 && w.owner != "Window Server" && w.bounds.contains(p) && (pid == nil || w.pid == pid!)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
func appInfo(_ pid: pid_t) -> (bundleId: String?, name: String?) {
|
|
861
|
+
guard let app = NSRunningApplication(processIdentifier: pid) else { return (nil, nil) }
|
|
862
|
+
return (app.bundleIdentifier, app.localizedName)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
func frontmostJSON() -> Any {
|
|
866
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return NSNull() }
|
|
867
|
+
return ["pid": Int(app.processIdentifier), "bundleId": app.bundleIdentifier as Any, "name": app.localizedName as Any]
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// MARK: - Session, permissions, responsible app
|
|
871
|
+
|
|
872
|
+
func sessionInfo() -> (locked: Bool, onConsole: Bool) {
|
|
873
|
+
// No dictionary → not inside a GUI login session: treat as off-console (fail closed).
|
|
874
|
+
guard let d = CGSessionCopyCurrentDictionary() as? [String: Any] else { return (false, false) }
|
|
875
|
+
let locked = (d["CGSSessionScreenIsLocked"] as? NSNumber)?.boolValue ?? false
|
|
876
|
+
let onConsole = (d[kCGSessionOnConsoleKey] as? NSNumber)?.boolValue ?? true
|
|
877
|
+
return (locked, onConsole)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
func permissionFlags() -> [String: Bool] {
|
|
881
|
+
[
|
|
882
|
+
"screenRecording": CGPreflightScreenCaptureAccess(),
|
|
883
|
+
"accessibility": AXIsProcessTrusted(),
|
|
884
|
+
"postEvents": CGPreflightPostEventAccess(),
|
|
885
|
+
"inputMonitoring": CGPreflightListenEventAccess(),
|
|
886
|
+
]
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
func permissionsJSON() -> [String: Any] {
|
|
890
|
+
var out: [String: Any] = permissionFlags()
|
|
891
|
+
out["responsibleApp"] = ResponsibleApp.info ?? NSNull()
|
|
892
|
+
return out
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
enum ResponsibleApp {
|
|
896
|
+
/// The app macOS attributes our TCC permissions to: the nearest ancestor process
|
|
897
|
+
/// whose executable lives inside a .app bundle (outermost bundle of its path).
|
|
898
|
+
static let info: [String: Any]? = compute()
|
|
899
|
+
|
|
900
|
+
static func parentPid(_ pid: pid_t) -> pid_t? {
|
|
901
|
+
var kinfo = kinfo_proc()
|
|
902
|
+
var size = MemoryLayout<kinfo_proc>.stride
|
|
903
|
+
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
|
904
|
+
guard sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0, size > 0 else { return nil }
|
|
905
|
+
return kinfo.kp_eproc.e_ppid
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
static func path(_ pid: pid_t) -> String? {
|
|
909
|
+
var buf = [CChar](repeating: 0, count: 4096)
|
|
910
|
+
guard proc_pidpath(pid, &buf, UInt32(buf.count)) > 0 else { return nil }
|
|
911
|
+
return String(cString: buf)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
static func compute() -> [String: Any]? {
|
|
915
|
+
var pid = getppid()
|
|
916
|
+
var hops = 0
|
|
917
|
+
while pid > 1 && hops < 64 {
|
|
918
|
+
if let p = path(pid), let r = p.range(of: ".app/") {
|
|
919
|
+
let bundlePath = String(p[..<r.lowerBound]) + ".app"
|
|
920
|
+
let bundle = Bundle(path: bundlePath)
|
|
921
|
+
let bid = bundle?.bundleIdentifier
|
|
922
|
+
let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
|
|
923
|
+
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
|
|
924
|
+
?? URL(fileURLWithPath: bundlePath).deletingPathExtension().lastPathComponent
|
|
925
|
+
var rpid = Int(pid)
|
|
926
|
+
if let bid = bid {
|
|
927
|
+
let running = NSRunningApplication.runningApplications(withBundleIdentifier: bid)
|
|
928
|
+
if let app = running.first(where: { $0.bundleURL?.path == bundlePath }) ?? running.first {
|
|
929
|
+
rpid = Int(app.processIdentifier)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return ["name": name, "bundleId": bid as Any, "path": bundlePath, "pid": rpid]
|
|
933
|
+
}
|
|
934
|
+
guard let pp = parentPid(pid), pp != pid else { break }
|
|
935
|
+
pid = pp
|
|
936
|
+
hops += 1
|
|
937
|
+
}
|
|
938
|
+
return nil
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
func sessionStateJSON() -> [String: Any] {
|
|
943
|
+
let s = sessionInfo()
|
|
944
|
+
return ["locked": s.locked, "onConsole": s.onConsole, "secureInput": IsSecureEventInputEnabled(), "frontmost": frontmostJSON()]
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// MARK: - Accessibility
|
|
948
|
+
|
|
949
|
+
enum AX {
|
|
950
|
+
static let systemWide = AXUIElementCreateSystemWide()
|
|
951
|
+
private static var prepared = false
|
|
952
|
+
|
|
953
|
+
/// Bound every AX round-trip so one hung app can't wedge the action queue.
|
|
954
|
+
static func prepare() {
|
|
955
|
+
if prepared { return }
|
|
956
|
+
prepared = true
|
|
957
|
+
AXUIElementSetMessagingTimeout(systemWide, 1.5)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
static func copy(_ el: AXUIElement, _ attr: String) -> (AXError, CFTypeRef?) {
|
|
961
|
+
var v: CFTypeRef?
|
|
962
|
+
let e = AXUIElementCopyAttributeValue(el, attr as CFString, &v)
|
|
963
|
+
return (e, v)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
static func string(_ el: AXUIElement, _ attr: String) -> String? {
|
|
967
|
+
let (e, v) = copy(el, attr)
|
|
968
|
+
return e == .success ? v as? String : nil
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
static func element(_ v: CFTypeRef?) -> AXUIElement? {
|
|
972
|
+
guard let v = v, CFGetTypeID(v) == AXUIElementGetTypeID() else { return nil }
|
|
973
|
+
return (v as! AXUIElement)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/// Missing attributes come back as nil (AX packs them as kAXValueAXErrorType).
|
|
977
|
+
static func multi(_ el: AXUIElement, _ attrs: [String]) -> (AXError, [CFTypeRef?]) {
|
|
978
|
+
var out: CFArray?
|
|
979
|
+
let e = AXUIElementCopyMultipleAttributeValues(el, attrs as CFArray, AXCopyMultipleAttributeOptions(rawValue: 0), &out)
|
|
980
|
+
guard e == .success, let arr = out as [AnyObject]?, arr.count == attrs.count else {
|
|
981
|
+
return (e == .success ? .failure : e, Array(repeating: nil, count: attrs.count))
|
|
982
|
+
}
|
|
983
|
+
return (.success, arr.map { v -> CFTypeRef? in
|
|
984
|
+
if CFGetTypeID(v) == AXValueGetTypeID(), AXValueGetType(v as! AXValue) == .axError { return nil }
|
|
985
|
+
if v is NSNull { return nil }
|
|
986
|
+
return v
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
static func point(_ v: CFTypeRef?) -> CGPoint? {
|
|
991
|
+
guard let v = v, CFGetTypeID(v) == AXValueGetTypeID() else { return nil }
|
|
992
|
+
var p = CGPoint.zero
|
|
993
|
+
return AXValueGetValue(v as! AXValue, .cgPoint, &p) ? p : nil
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
static func size(_ v: CFTypeRef?) -> CGSize? {
|
|
997
|
+
guard let v = v, CFGetTypeID(v) == AXValueGetTypeID() else { return nil }
|
|
998
|
+
var s = CGSize.zero
|
|
999
|
+
return AXValueGetValue(v as! AXValue, .cgSize, &s) ? s : nil
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
static func frame(_ el: AXUIElement) -> CGRect? {
|
|
1003
|
+
let (_, vals) = multi(el, ["AXPosition", "AXSize"])
|
|
1004
|
+
guard let p = point(vals[0]), let s = size(vals[1]) else { return nil }
|
|
1005
|
+
return CGRect(origin: p, size: s)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
static func actions(_ el: AXUIElement) -> [String] {
|
|
1009
|
+
var names: CFArray?
|
|
1010
|
+
guard AXUIElementCopyActionNames(el, &names) == .success, let list = names as? [String] else { return [] }
|
|
1011
|
+
return list
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
static func children(_ el: AXUIElement, limit: Int) -> [AXUIElement] {
|
|
1015
|
+
var out: CFArray?
|
|
1016
|
+
guard AXUIElementCopyAttributeValues(el, "AXChildren" as CFString, 0, limit, &out) == .success,
|
|
1017
|
+
let arr = out as [AnyObject]? else { return [] }
|
|
1018
|
+
return arr.compactMap { element($0) }
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
static func pid(_ el: AXUIElement) -> pid_t? {
|
|
1022
|
+
var pid: pid_t = 0
|
|
1023
|
+
return AXUIElementGetPid(el, &pid) == .success && pid > 0 ? pid : nil
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
static func setBool(_ el: AXUIElement, _ attr: String, _ value: Bool) -> AXError {
|
|
1027
|
+
AXUIElementSetAttributeValue(el, attr as CFString, (value ? kCFBooleanTrue : kCFBooleanFalse)!)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
static func perform(_ el: AXUIElement, _ action: String) -> AXError {
|
|
1031
|
+
AXUIElementPerformAction(el, action as CFString)
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
func axFail(_ e: AXError, _ what: String) -> HelperError {
|
|
1036
|
+
switch e {
|
|
1037
|
+
case .apiDisabled: return HelperError("NOT_TRUSTED", "Accessibility permission is not granted")
|
|
1038
|
+
case .invalidUIElement: return HelperError("REF_EXPIRED", "\(what): the element no longer exists")
|
|
1039
|
+
case .cannotComplete: return HelperError("AX_TIMEOUT", "\(what): the application did not answer in time")
|
|
1040
|
+
case .notImplemented: return HelperError("UNSUPPORTED", "\(what): the application does not implement it")
|
|
1041
|
+
default: return HelperError("AX_ERROR", "\(what) failed (AXError \(e.rawValue))", ["axError": Int(e.rawValue)])
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
typealias AXGetWindowFn = @convention(c) (AXUIElement, UnsafeMutablePointer<CGWindowID>) -> AXError
|
|
1046
|
+
/// _AXUIElementGetWindow is private but stable for over a decade (window managers
|
|
1047
|
+
/// rely on it). Resolved dynamically so its absence only costs the frame fallback.
|
|
1048
|
+
let axGetWindow: AXGetWindowFn? = {
|
|
1049
|
+
guard let h = dlopen(nil, RTLD_NOW), let sym = dlsym(h, "_AXUIElementGetWindow") else { return nil }
|
|
1050
|
+
return unsafeBitCast(sym, to: AXGetWindowFn.self)
|
|
1051
|
+
}()
|
|
1052
|
+
|
|
1053
|
+
func windowID(of win: AXUIElement, pid: pid_t) -> Int? {
|
|
1054
|
+
if let f = axGetWindow {
|
|
1055
|
+
var wid: CGWindowID = 0
|
|
1056
|
+
if f(win, &wid) == .success, wid != 0 { return Int(wid) }
|
|
1057
|
+
}
|
|
1058
|
+
guard let fr = AX.frame(win) else { return nil }
|
|
1059
|
+
let near = { (a: CGFloat, b: CGFloat) in abs(a - b) <= 2 }
|
|
1060
|
+
return Windows.list(onScreenOnly: false).first { w in
|
|
1061
|
+
w.pid == Int(pid) && w.layer == 0 && near(w.bounds.minX, fr.minX) && near(w.bounds.minY, fr.minY)
|
|
1062
|
+
&& near(w.bounds.width, fr.width) && near(w.bounds.height, fr.height)
|
|
1063
|
+
}?.windowId
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
let TEXT_ROLES: Set<String> = ["AXTextField", "AXTextArea", "AXComboBox"]
|
|
1067
|
+
|
|
1068
|
+
/// Never called for secure fields. Long text is read through AXStringForRange so a
|
|
1069
|
+
/// big document isn't copied just to show a preview.
|
|
1070
|
+
func readValue(_ el: AXUIElement, role: String?, limit: Int) -> Any? {
|
|
1071
|
+
if let role = role, TEXT_ROLES.contains(role) {
|
|
1072
|
+
var range = CFRange(location: 0, length: limit)
|
|
1073
|
+
if let param = AXValueCreate(.cfRange, &range) {
|
|
1074
|
+
var v: CFTypeRef?
|
|
1075
|
+
if AXUIElementCopyParameterizedAttributeValue(el, "AXStringForRange" as CFString, param, &v) == .success, let s = v as? String {
|
|
1076
|
+
return s
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
let (e, v) = AX.copy(el, "AXValue")
|
|
1081
|
+
guard e == .success, let v = v else { return nil }
|
|
1082
|
+
if let s = v as? String { return s.count > limit ? String(s.prefix(limit)) : s }
|
|
1083
|
+
if CFGetTypeID(v) == CFBooleanGetTypeID() { return (v as! NSNumber).boolValue }
|
|
1084
|
+
if let n = v as? NSNumber { return n }
|
|
1085
|
+
return nil
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
let PROBE_ATTRS = ["AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXEnabled", "AXPosition", "AXSize", "AXWindow"]
|
|
1089
|
+
|
|
1090
|
+
func probeElement(_ el: AXUIElement) -> Probe {
|
|
1091
|
+
var p = Probe()
|
|
1092
|
+
if let pid = AX.pid(el) {
|
|
1093
|
+
p.pid = Int(pid)
|
|
1094
|
+
let info = appInfo(pid)
|
|
1095
|
+
p.bundleId = info.bundleId
|
|
1096
|
+
p.app = info.name
|
|
1097
|
+
}
|
|
1098
|
+
let (err, vals) = AX.multi(el, PROBE_ATTRS)
|
|
1099
|
+
p.axError = err
|
|
1100
|
+
guard err == .success else {
|
|
1101
|
+
p.readable = false
|
|
1102
|
+
return p
|
|
1103
|
+
}
|
|
1104
|
+
p.role = vals[0] as? String
|
|
1105
|
+
p.subrole = vals[1] as? String
|
|
1106
|
+
p.title = trimmed(vals[2] as? String, 200)
|
|
1107
|
+
p.desc = trimmed(vals[3] as? String, 200)
|
|
1108
|
+
p.enabled = (vals[4] as? NSNumber)?.boolValue
|
|
1109
|
+
if let pt = AX.point(vals[5]), let sz = AX.size(vals[6]) { p.frame = CGRect(origin: pt, size: sz) }
|
|
1110
|
+
p.secure = p.subrole == "AXSecureTextField" || p.role == "AXSecureTextField"
|
|
1111
|
+
if p.role == nil && p.subrole == nil { p.readable = false }
|
|
1112
|
+
if !p.secure, let v = readValue(el, role: p.role, limit: 80) {
|
|
1113
|
+
p.valuePreview = (v as? String) ?? (v as? NSNumber)?.stringValue ?? (v as? Bool).map { $0 ? "true" : "false" }
|
|
1114
|
+
}
|
|
1115
|
+
let win: AXUIElement? = p.role == "AXWindow" ? el : AX.element(vals[7])
|
|
1116
|
+
if let win = win {
|
|
1117
|
+
p.windowTitle = AX.string(win, "AXTitle").flatMap { $0.isEmpty ? nil : $0 }
|
|
1118
|
+
if let pid = p.pid { p.windowId = windowID(of: win, pid: pid_t(pid)) }
|
|
1119
|
+
}
|
|
1120
|
+
return p
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
func probeAt(_ pt: CGPoint) throws -> Probe {
|
|
1124
|
+
AX.prepare()
|
|
1125
|
+
var found: AXUIElement?
|
|
1126
|
+
let err = AXUIElementCopyElementAtPosition(AX.systemWide, Float(pt.x), Float(pt.y), &found)
|
|
1127
|
+
if err == .apiDisabled { throw axFail(err, "probe") }
|
|
1128
|
+
var p = Probe()
|
|
1129
|
+
if err == .success, let el = found {
|
|
1130
|
+
p = probeElement(el)
|
|
1131
|
+
} else {
|
|
1132
|
+
p.readable = false
|
|
1133
|
+
p.axError = err
|
|
1134
|
+
}
|
|
1135
|
+
// Fill in from the window list when AX couldn't (hung or AX-less apps) so the
|
|
1136
|
+
// guard still sees which app and window sit under the point.
|
|
1137
|
+
if p.pid == nil || p.windowId == nil || p.windowTitle == nil {
|
|
1138
|
+
if let w = Windows.at(pt, pid: p.pid) {
|
|
1139
|
+
if p.pid == nil {
|
|
1140
|
+
p.pid = w.pid
|
|
1141
|
+
let info = appInfo(pid_t(w.pid))
|
|
1142
|
+
p.bundleId = info.bundleId
|
|
1143
|
+
p.app = info.name ?? w.owner
|
|
1144
|
+
}
|
|
1145
|
+
if p.windowId == nil { p.windowId = w.windowId }
|
|
1146
|
+
if p.windowTitle == nil { p.windowTitle = w.title }
|
|
1147
|
+
if p.frame == nil { p.frame = w.bounds }
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return p
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/// The frontmost app's focused window (role AXWindow), used for keyboard guards.
|
|
1154
|
+
func frontmostWindowProbe() -> Probe? {
|
|
1155
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
1156
|
+
var p = Probe()
|
|
1157
|
+
p.pid = Int(app.processIdentifier)
|
|
1158
|
+
p.bundleId = app.bundleIdentifier
|
|
1159
|
+
p.app = app.localizedName
|
|
1160
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
1161
|
+
let (e, v) = AX.copy(axApp, "AXFocusedWindow")
|
|
1162
|
+
if e == .success, let win = AX.element(v) {
|
|
1163
|
+
p.role = "AXWindow"
|
|
1164
|
+
p.windowTitle = AX.string(win, "AXTitle").flatMap { $0.isEmpty ? nil : $0 }
|
|
1165
|
+
p.windowId = windowID(of: win, pid: app.processIdentifier)
|
|
1166
|
+
p.frame = AX.frame(win)
|
|
1167
|
+
} else {
|
|
1168
|
+
p.readable = false
|
|
1169
|
+
p.axError = e
|
|
1170
|
+
}
|
|
1171
|
+
return p
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
func probeFocus() throws -> Probe {
|
|
1175
|
+
AX.prepare()
|
|
1176
|
+
let (e, v) = AX.copy(AX.systemWide, "AXFocusedUIElement")
|
|
1177
|
+
if e == .apiDisabled { throw axFail(e, "probe_focus") }
|
|
1178
|
+
if e == .success, let el = AX.element(v) { return probeElement(el) }
|
|
1179
|
+
var p = frontmostWindowProbe() ?? Probe()
|
|
1180
|
+
p.readable = false
|
|
1181
|
+
return p
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// MARK: - Input
|
|
1185
|
+
|
|
1186
|
+
enum Btn: String, CaseIterable {
|
|
1187
|
+
case left, right, middle
|
|
1188
|
+
|
|
1189
|
+
var cg: CGMouseButton {
|
|
1190
|
+
switch self {
|
|
1191
|
+
case .left: return .left
|
|
1192
|
+
case .right: return .right
|
|
1193
|
+
case .middle: return .center
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
var down: CGEventType {
|
|
1197
|
+
switch self {
|
|
1198
|
+
case .left: return .leftMouseDown
|
|
1199
|
+
case .right: return .rightMouseDown
|
|
1200
|
+
case .middle: return .otherMouseDown
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
var up: CGEventType {
|
|
1204
|
+
switch self {
|
|
1205
|
+
case .left: return .leftMouseUp
|
|
1206
|
+
case .right: return .rightMouseUp
|
|
1207
|
+
case .middle: return .otherMouseUp
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
var dragged: CGEventType {
|
|
1211
|
+
switch self {
|
|
1212
|
+
case .left: return .leftMouseDragged
|
|
1213
|
+
case .right: return .rightMouseDragged
|
|
1214
|
+
case .middle: return .otherMouseDragged
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
func cursorLocation() -> CGPoint { CGEvent(source: nil)?.location ?? .zero }
|
|
1220
|
+
|
|
1221
|
+
final class Input {
|
|
1222
|
+
static let shared = Input()
|
|
1223
|
+
|
|
1224
|
+
/// One private source: our modifier state never mixes with the user's, and
|
|
1225
|
+
/// local-event suppression is off so the user can always take over.
|
|
1226
|
+
let source: CGEventSource?
|
|
1227
|
+
private let lock = NSLock()
|
|
1228
|
+
private var gen: UInt64 = 0
|
|
1229
|
+
private var heldButtons: [Btn] = []
|
|
1230
|
+
private var heldKeys: Set<CGKeyCode> = []
|
|
1231
|
+
private var latched = false
|
|
1232
|
+
private var lastUserInterruptNs: UInt64 = 0
|
|
1233
|
+
|
|
1234
|
+
init() {
|
|
1235
|
+
source = CGEventSource(stateID: .privateState)
|
|
1236
|
+
source?.localEventsSuppressionInterval = 0
|
|
1237
|
+
source?.userData = SYNB_STAMP
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
var generation: UInt64 { lock.lock(); defer { lock.unlock() }; return gen }
|
|
1241
|
+
var isLatched: Bool { lock.lock(); defer { lock.unlock() }; return latched }
|
|
1242
|
+
|
|
1243
|
+
func setLatched(_ v: Bool) { lock.lock(); latched = v; lock.unlock() }
|
|
1244
|
+
|
|
1245
|
+
func noteUserInterrupt(_ ns: UInt64) { lock.lock(); lastUserInterruptNs = ns; lock.unlock() }
|
|
1246
|
+
|
|
1247
|
+
func userInterrupted(since ns: UInt64) -> Bool { lock.lock(); defer { lock.unlock() }; return lastUserInterruptNs > ns }
|
|
1248
|
+
|
|
1249
|
+
func checkAbort(_ g: UInt64) throws {
|
|
1250
|
+
if generation != g { throw HelperError("ABORTED", "the action was aborted") }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
func sleepAbortable(_ ms: Double, _ g: UInt64) throws {
|
|
1254
|
+
let end = nowNs() + UInt64(max(0, ms) * 1_000_000)
|
|
1255
|
+
while nowNs() < end {
|
|
1256
|
+
try checkAbort(g)
|
|
1257
|
+
sleepMs(min(20, Double(end - nowNs()) / 1_000_000))
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
func post(_ e: CGEvent?) {
|
|
1262
|
+
guard let e = e else { return }
|
|
1263
|
+
e.setIntegerValueField(.eventSourceUserData, value: SYNB_STAMP)
|
|
1264
|
+
e.post(tap: .cghidEventTap)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// ── held-state tracking (so abort / panic / exit can release it) ──
|
|
1268
|
+
private func mark(button b: Btn, _ down: Bool) {
|
|
1269
|
+
lock.lock()
|
|
1270
|
+
if down { if !heldButtons.contains(b) { heldButtons.append(b) } } else { heldButtons.removeAll { $0 == b } }
|
|
1271
|
+
lock.unlock()
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private func mark(key k: CGKeyCode, _ down: Bool) {
|
|
1275
|
+
lock.lock()
|
|
1276
|
+
if down { heldKeys.insert(k) } else { heldKeys.remove(k) }
|
|
1277
|
+
lock.unlock()
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
var heldButton: Btn? { lock.lock(); defer { lock.unlock() }; return heldButtons.last }
|
|
1281
|
+
|
|
1282
|
+
func isHeld(_ b: Btn) -> Bool { lock.lock(); defer { lock.unlock() }; return heldButtons.contains(b) }
|
|
1283
|
+
|
|
1284
|
+
/// Releases exactly what this process holds. Posts nothing when nothing is held.
|
|
1285
|
+
func releaseTracked() {
|
|
1286
|
+
lock.lock()
|
|
1287
|
+
let buttons = heldButtons
|
|
1288
|
+
let keys = heldKeys
|
|
1289
|
+
heldButtons = []
|
|
1290
|
+
heldKeys = []
|
|
1291
|
+
lock.unlock()
|
|
1292
|
+
guard !buttons.isEmpty || !keys.isEmpty, CGPreflightPostEventAccess() else { return }
|
|
1293
|
+
let pos = cursorLocation()
|
|
1294
|
+
for b in buttons { post(CGEvent(mouseEventSource: source, mouseType: b.up, mouseCursorPosition: pos, mouseButton: b.cg)) }
|
|
1295
|
+
for k in keys { keyEvent(k, down: false, flags: []) }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/// Cancels the in-flight action (it notices at its next check) and releases held input.
|
|
1299
|
+
func abort() {
|
|
1300
|
+
lock.lock()
|
|
1301
|
+
gen &+= 1
|
|
1302
|
+
lock.unlock()
|
|
1303
|
+
releaseTracked()
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/// After a respawn the previous process's held state is unknown: besides what we
|
|
1307
|
+
/// track, release every modifier and mouse button the session reports as down.
|
|
1308
|
+
/// Nothing is posted when nothing is down.
|
|
1309
|
+
func panic() {
|
|
1310
|
+
abort()
|
|
1311
|
+
guard CGPreflightPostEventAccess() else { return }
|
|
1312
|
+
let flags = CGEventSource.flagsState(.combinedSessionState)
|
|
1313
|
+
for m in Mod.allCases where flags.contains(m.flag) {
|
|
1314
|
+
keyEvent(m.keyCode, down: false, flags: [])
|
|
1315
|
+
if let r = m.rightKeyCode { keyEvent(r, down: false, flags: []) }
|
|
1316
|
+
}
|
|
1317
|
+
let pos = cursorLocation()
|
|
1318
|
+
for b in Btn.allCases where CGEventSource.buttonState(.combinedSessionState, button: b.cg) {
|
|
1319
|
+
post(CGEvent(mouseEventSource: source, mouseType: b.up, mouseCursorPosition: pos, mouseButton: b.cg))
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ── pointer ──
|
|
1324
|
+
func postPointer(_ pt: CGPoint) {
|
|
1325
|
+
let held = heldButton
|
|
1326
|
+
post(CGEvent(mouseEventSource: source, mouseType: held?.dragged ?? .mouseMoved, mouseCursorPosition: pt, mouseButton: held?.cg ?? .left))
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
func move(to target: CGPoint, steps: Int, gen g: UInt64) throws {
|
|
1330
|
+
let start = cursorLocation()
|
|
1331
|
+
let n = clampInt(steps, 1, 200)
|
|
1332
|
+
for i in 1...n {
|
|
1333
|
+
try checkAbort(g)
|
|
1334
|
+
let t = CGFloat(i) / CGFloat(n)
|
|
1335
|
+
postPointer(CGPoint(x: start.x + (target.x - start.x) * t, y: start.y + (target.y - start.y) * t))
|
|
1336
|
+
if i < n { sleepMs(4) }
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
func button(_ b: Btn, down: Bool, at p: CGPoint, clickState: Int = 1, flags: CGEventFlags = []) {
|
|
1341
|
+
let e = CGEvent(mouseEventSource: source, mouseType: down ? b.down : b.up, mouseCursorPosition: p, mouseButton: b.cg)
|
|
1342
|
+
e?.setIntegerValueField(.mouseEventClickState, value: Int64(clickState))
|
|
1343
|
+
e?.flags = flags
|
|
1344
|
+
if down { mark(button: b, true) }
|
|
1345
|
+
post(e)
|
|
1346
|
+
if !down { mark(button: b, false) }
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
func click(at p: CGPoint, button b: Btn, count: Int, flags: CGEventFlags, gen g: UInt64) throws {
|
|
1350
|
+
for c in 1...count {
|
|
1351
|
+
try checkAbort(g)
|
|
1352
|
+
button(b, down: true, at: p, clickState: c, flags: flags)
|
|
1353
|
+
sleepMs(15)
|
|
1354
|
+
button(b, down: false, at: p, clickState: c, flags: flags)
|
|
1355
|
+
if c < count { sleepMs(35) }
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
func scroll(at p: CGPoint, dx: Int, dy: Int, flags: CGEventFlags, gen g: UInt64) throws {
|
|
1360
|
+
let (w1, w2) = scrollWheelDeltas(dx: dx, dy: dy)
|
|
1361
|
+
var r1 = Int(w1), r2 = Int(w2)
|
|
1362
|
+
// Several small events instead of one huge delta: apps clamp or accelerate big ones.
|
|
1363
|
+
while r1 != 0 || r2 != 0 {
|
|
1364
|
+
try checkAbort(g)
|
|
1365
|
+
let s1 = clampInt(r1, -5, 5), s2 = clampInt(r2, -5, 5)
|
|
1366
|
+
let e = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 2, wheel1: Int32(s1), wheel2: Int32(s2), wheel3: 0)
|
|
1367
|
+
e?.location = p
|
|
1368
|
+
e?.flags = flags
|
|
1369
|
+
post(e)
|
|
1370
|
+
r1 -= s1
|
|
1371
|
+
r2 -= s2
|
|
1372
|
+
if r1 != 0 || r2 != 0 { sleepMs(15) }
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ── keyboard ──
|
|
1377
|
+
func keyEvent(_ code: CGKeyCode, down: Bool, flags: CGEventFlags) {
|
|
1378
|
+
let e = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
|
|
1379
|
+
e?.flags = flags
|
|
1380
|
+
post(e)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
func tap(_ code: CGKeyCode, flags: CGEventFlags = []) {
|
|
1384
|
+
mark(key: code, true)
|
|
1385
|
+
keyEvent(code, down: true, flags: flags)
|
|
1386
|
+
sleepMs(4)
|
|
1387
|
+
keyEvent(code, down: false, flags: flags)
|
|
1388
|
+
mark(key: code, false)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/// Modifiers go down in order (as real key events carrying the accumulated flags),
|
|
1392
|
+
/// the key is pressed `repeatCount` times (held for holdMs when given), then the
|
|
1393
|
+
/// modifiers are released in reverse — also when aborted.
|
|
1394
|
+
func pressCombo(_ combo: KeyCombo, repeatCount: Int, holdMs: Double, gen g: UInt64) throws {
|
|
1395
|
+
var flags = CGEventFlags()
|
|
1396
|
+
var pressed: [Mod] = []
|
|
1397
|
+
defer {
|
|
1398
|
+
for m in pressed.reversed() {
|
|
1399
|
+
flags.remove(m.flag)
|
|
1400
|
+
keyEvent(m.keyCode, down: false, flags: flags)
|
|
1401
|
+
mark(key: m.keyCode, false)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
for m in combo.mods {
|
|
1405
|
+
try checkAbort(g)
|
|
1406
|
+
flags.insert(m.flag)
|
|
1407
|
+
mark(key: m.keyCode, true)
|
|
1408
|
+
keyEvent(m.keyCode, down: true, flags: flags)
|
|
1409
|
+
pressed.append(m)
|
|
1410
|
+
sleepMs(6)
|
|
1411
|
+
}
|
|
1412
|
+
if let code = combo.keyCode {
|
|
1413
|
+
let n = clampInt(repeatCount, 1, 100)
|
|
1414
|
+
for i in 0..<n {
|
|
1415
|
+
try checkAbort(g)
|
|
1416
|
+
mark(key: code, true)
|
|
1417
|
+
keyEvent(code, down: true, flags: flags)
|
|
1418
|
+
do {
|
|
1419
|
+
if holdMs > 0 { try sleepAbortable(holdMs, g) } else { sleepMs(8) }
|
|
1420
|
+
} catch {
|
|
1421
|
+
keyEvent(code, down: false, flags: flags)
|
|
1422
|
+
mark(key: code, false)
|
|
1423
|
+
throw error
|
|
1424
|
+
}
|
|
1425
|
+
keyEvent(code, down: false, flags: flags)
|
|
1426
|
+
mark(key: code, false)
|
|
1427
|
+
if i < n - 1 { sleepMs(25) }
|
|
1428
|
+
}
|
|
1429
|
+
} else if holdMs > 0 {
|
|
1430
|
+
try sleepAbortable(holdMs, g)
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/// CGEventKeyboardSetUnicodeString carries at most 20 UTF-16 units per event;
|
|
1435
|
+
/// longer strings are split without breaking a surrogate pair.
|
|
1436
|
+
func typeUnicode(_ s: String) {
|
|
1437
|
+
let units = Array(s.utf16)
|
|
1438
|
+
var idx = 0
|
|
1439
|
+
while idx < units.count {
|
|
1440
|
+
var end = min(idx + 20, units.count)
|
|
1441
|
+
if end < units.count, end - idx > 1, UTF16.isLeadSurrogate(units[end - 1]) { end -= 1 }
|
|
1442
|
+
var slice = Array(units[idx..<end])
|
|
1443
|
+
let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)
|
|
1444
|
+
down?.flags = []
|
|
1445
|
+
down?.keyboardSetUnicodeString(stringLength: slice.count, unicodeString: &slice)
|
|
1446
|
+
post(down)
|
|
1447
|
+
let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)
|
|
1448
|
+
up?.flags = []
|
|
1449
|
+
up?.keyboardSetUnicodeString(stringLength: slice.count, unicodeString: &slice)
|
|
1450
|
+
post(up)
|
|
1451
|
+
idx = end
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/// keys mode: real key codes for the current layout; characters the layout can't
|
|
1456
|
+
/// produce fall back to the unicode path.
|
|
1457
|
+
func typeKeys(_ s: String, layout: LayoutMap) {
|
|
1458
|
+
for ch in s {
|
|
1459
|
+
if let r = resolveChar(ch, layout: layout, ansiFallback: false) {
|
|
1460
|
+
var f = CGEventFlags()
|
|
1461
|
+
if r.shift { f.insert(.maskShift) }
|
|
1462
|
+
if r.alt { f.insert(.maskAlternate) }
|
|
1463
|
+
tap(r.code, flags: f)
|
|
1464
|
+
sleepMs(4)
|
|
1465
|
+
} else {
|
|
1466
|
+
typeUnicode(String(ch))
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/// Reverse map of the current keyboard layout (UCKeyTranslate over every key code
|
|
1473
|
+
/// and the none/shift/option/shift+option states). TIS must run on the main thread.
|
|
1474
|
+
func currentLayoutMap() -> LayoutMap {
|
|
1475
|
+
onMain {
|
|
1476
|
+
var map: LayoutMap = [:]
|
|
1477
|
+
guard let src = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
|
|
1478
|
+
let raw = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData) else { return map }
|
|
1479
|
+
let data = Unmanaged<CFData>.fromOpaque(raw).takeUnretainedValue()
|
|
1480
|
+
guard let bytes = CFDataGetBytePtr(data) else { return map }
|
|
1481
|
+
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
|
|
1482
|
+
let kbType = UInt32(LMGetKbdType())
|
|
1483
|
+
for state: UInt32 in [0, 2, 8, 10] {
|
|
1484
|
+
for code in 0..<128 {
|
|
1485
|
+
var dead: UInt32 = 0
|
|
1486
|
+
var len = 0
|
|
1487
|
+
var chars = [UniChar](repeating: 0, count: 8)
|
|
1488
|
+
let st = UCKeyTranslate(layout, UInt16(code), UInt16(kUCKeyActionDown), state, kbType,
|
|
1489
|
+
OptionBits(kUCKeyTranslateNoDeadKeysMask), &dead, chars.count, &len, &chars)
|
|
1490
|
+
guard st == noErr, len > 0 else { continue }
|
|
1491
|
+
let s = String(utf16CodeUnits: chars, count: len)
|
|
1492
|
+
guard let first = s.unicodeScalars.first, !CharacterSet.controlCharacters.contains(first) else { continue }
|
|
1493
|
+
if map[s] == nil { map[s] = (CGKeyCode(code), state) }
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return map
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
func parseButton(_ s: String?) throws -> Btn {
|
|
1501
|
+
guard let s = s else { return .left }
|
|
1502
|
+
guard let b = Btn(rawValue: s.lowercased()) else { throw HelperError("BAD_ARGS", "button must be left, right or middle") }
|
|
1503
|
+
return b
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
func parseModifiers(_ arr: [Any]?) throws -> CGEventFlags {
|
|
1507
|
+
var f = CGEventFlags()
|
|
1508
|
+
for item in arr ?? [] {
|
|
1509
|
+
guard let s = item as? String, let m = Mod.parse(s) else { throw HelperError("BAD_ARGS", "unknown modifier \(item)") }
|
|
1510
|
+
f.insert(m.flag)
|
|
1511
|
+
}
|
|
1512
|
+
return f
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// MARK: - Guards
|
|
1516
|
+
|
|
1517
|
+
func requireTrusted() throws {
|
|
1518
|
+
guard AXIsProcessTrusted() else {
|
|
1519
|
+
throw HelperError("NOT_TRUSTED", "Accessibility permission is required", ["responsibleApp": ResponsibleApp.info as Any])
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
func requireNotLatched() throws {
|
|
1524
|
+
if Input.shared.isLatched {
|
|
1525
|
+
throw HelperError("INTERRUPTED", "an emergency stop is latched; send configure to resume", ["latched": true])
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
func requireUnlocked() throws {
|
|
1530
|
+
guard Config.guardCfg.refuseWhenLocked else { return }
|
|
1531
|
+
let s = sessionInfo()
|
|
1532
|
+
if s.locked || !s.onConsole {
|
|
1533
|
+
throw HelperError("SCREEN_LOCKED", s.locked ? "the screen is locked" : "the login session is not on the console",
|
|
1534
|
+
["locked": s.locked, "onConsole": s.onConsole])
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
func requireOnScreen(_ p: CGPoint) throws {
|
|
1539
|
+
if Displays.containing(p) == nil {
|
|
1540
|
+
throw HelperError("OUT_OF_BOUNDS", "(\(p.x), \(p.y)) is not on any display", ["point": pointJSON(p)])
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/// Every input/AX action: permission, emergency latch, lock state.
|
|
1545
|
+
func preInput() throws {
|
|
1546
|
+
try requireTrusted()
|
|
1547
|
+
try requireNotLatched()
|
|
1548
|
+
try requireUnlocked()
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
func guardPoint(_ p: CGPoint, context: String) throws -> Probe {
|
|
1552
|
+
let probe = try probeAt(p)
|
|
1553
|
+
if let e = matchGuard(probe, Config.guardCfg, context: context) { throw e }
|
|
1554
|
+
return probe
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/// Keyboard target: the focused element and the frontmost window must pass the
|
|
1558
|
+
/// app/window rules; printable input (or paste) must not reach a secure field, and
|
|
1559
|
+
/// fails closed when focus is unreadable while secure event input is on.
|
|
1560
|
+
func keyboardGuard(printable: Bool, context: String) throws -> Probe {
|
|
1561
|
+
try requireUnlocked()
|
|
1562
|
+
let g = Config.guardCfg
|
|
1563
|
+
let focus = try probeFocus()
|
|
1564
|
+
if let e = matchGuard(focus, g, context: context) { throw e }
|
|
1565
|
+
if let front = frontmostWindowProbe(), let e = matchGuard(front, g, context: context) { throw e }
|
|
1566
|
+
if printable && g.secureField {
|
|
1567
|
+
if focus.secure {
|
|
1568
|
+
throw HelperError("SECURE_FIELD", "the focused field is a password field", ["probe": focus.json, "context": context])
|
|
1569
|
+
}
|
|
1570
|
+
if !focus.readable && IsSecureEventInputEnabled() {
|
|
1571
|
+
throw HelperError("SECURE_FIELD", "secure input is active and the focused element can't be read",
|
|
1572
|
+
["probe": focus.json, "context": context, "secureInput": true])
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
return focus
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// MARK: - Screen capture (ScreenCaptureKit only; never called without the grant)
|
|
1579
|
+
|
|
1580
|
+
func requireCapture() throws {
|
|
1581
|
+
guard CGPreflightScreenCaptureAccess() else {
|
|
1582
|
+
throw HelperError("NO_SCREEN_RECORDING", "Screen Recording permission is required", ["responsibleApp": ResponsibleApp.info as Any])
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
func mapCaptureError(_ e: Error) -> HelperError {
|
|
1587
|
+
if let h = e as? HelperError { return h }
|
|
1588
|
+
let ns = e as NSError
|
|
1589
|
+
if ns.domain == SCStreamErrorDomain && ns.code == -3801 {
|
|
1590
|
+
return HelperError("NO_SCREEN_RECORDING", "Screen Recording permission was declined")
|
|
1591
|
+
}
|
|
1592
|
+
return HelperError("INTERNAL", "screen capture failed: \(ns.localizedDescription)", ["domain": ns.domain, "code": ns.code])
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
func shareableDisplay(_ id: CGDirectDisplayID) throws -> SCDisplay {
|
|
1596
|
+
let content: SCShareableContent
|
|
1597
|
+
do {
|
|
1598
|
+
content = try waitFor("ScreenCaptureKit", timeout: 10) { done in
|
|
1599
|
+
SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { c, e in done(c, e) }
|
|
1600
|
+
}
|
|
1601
|
+
} catch { throw mapCaptureError(error) }
|
|
1602
|
+
guard let d = content.displays.first(where: { $0.displayID == id }) else {
|
|
1603
|
+
throw HelperError("BAD_ARGS", "display \(id) is not available for capture")
|
|
1604
|
+
}
|
|
1605
|
+
return d
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
func sckCapture(_ filter: SCContentFilter, _ config: SCStreamConfiguration) throws -> CGImage {
|
|
1609
|
+
do {
|
|
1610
|
+
return try waitFor("ScreenCaptureKit", timeout: 10) { done in
|
|
1611
|
+
SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { img, e in done(img, e) }
|
|
1612
|
+
}
|
|
1613
|
+
} catch { throw mapCaptureError(error) }
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
func scaleImage(_ img: CGImage, _ w: Int, _ h: Int) -> CGImage? {
|
|
1617
|
+
guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
|
|
1618
|
+
let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: cs,
|
|
1619
|
+
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue) else { return nil }
|
|
1620
|
+
ctx.interpolationQuality = .high
|
|
1621
|
+
ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))
|
|
1622
|
+
return ctx.makeImage()
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
func encodeJPEG(_ img: CGImage, quality: Double) throws -> Data {
|
|
1626
|
+
let data = NSMutableData()
|
|
1627
|
+
guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, "public.jpeg" as CFString, 1, nil) else {
|
|
1628
|
+
throw HelperError("INTERNAL", "JPEG encoder unavailable")
|
|
1629
|
+
}
|
|
1630
|
+
CGImageDestinationAddImage(dest, img, [kCGImageDestinationLossyCompressionQuality: clampDouble(quality, 0.05, 1)] as CFDictionary)
|
|
1631
|
+
guard CGImageDestinationFinalize(dest) else { throw HelperError("INTERNAL", "JPEG encoding failed") }
|
|
1632
|
+
return data as Data
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
func parseFit(_ a: Args, defaultW: Double, defaultH: Double) -> (w: Double, h: Double) {
|
|
1636
|
+
let f = a.dict("fit")
|
|
1637
|
+
return (Args.num(f?["w"]) ?? defaultW, Args.num(f?["h"]) ?? defaultH)
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
func imageJSON(_ img: CGImage, quality: Double) throws -> [String: Any] {
|
|
1641
|
+
let jpeg = try encodeJPEG(img, quality: quality)
|
|
1642
|
+
return ["w": img.width, "h": img.height, "mime": "image/jpeg", "data": jpeg.base64EncodedString()]
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
func cmdScreenshot(_ a: Args) throws -> Any {
|
|
1646
|
+
try requireCapture()
|
|
1647
|
+
let did = a.num("displayId").map { CGDirectDisplayID(UInt32(clamping: toInt($0))) } ?? CGMainDisplayID()
|
|
1648
|
+
let bounds = CGDisplayBounds(did)
|
|
1649
|
+
guard bounds.width > 0, bounds.height > 0, Displays.ids().contains(did) else {
|
|
1650
|
+
throw HelperError("BAD_ARGS", "unknown display \(did)")
|
|
1651
|
+
}
|
|
1652
|
+
let scale = Displays.scale(did)
|
|
1653
|
+
let fit = parseFit(a, defaultW: Double(bounds.width), defaultH: Double(bounds.height))
|
|
1654
|
+
let size = fitSize(width: Double(bounds.width), height: Double(bounds.height), fitW: fit.w, fitH: fit.h, scale: scale)
|
|
1655
|
+
let display = try shareableDisplay(did)
|
|
1656
|
+
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
1657
|
+
let cfg = SCStreamConfiguration()
|
|
1658
|
+
cfg.width = size.w // SCK scales on the GPU to exactly this size
|
|
1659
|
+
cfg.height = size.h
|
|
1660
|
+
cfg.showsCursor = a.bool("showCursor") ?? false
|
|
1661
|
+
cfg.capturesAudio = false
|
|
1662
|
+
cfg.pixelFormat = kCVPixelFormatType_32BGRA
|
|
1663
|
+
cfg.colorSpaceName = CGColorSpace.sRGB
|
|
1664
|
+
cfg.captureResolution = .best
|
|
1665
|
+
var img = try sckCapture(filter, cfg)
|
|
1666
|
+
if img.width != size.w || img.height != size.h, let scaled = scaleImage(img, size.w, size.h) { img = scaled }
|
|
1667
|
+
let px = Displays.pixelSize(did)
|
|
1668
|
+
return [
|
|
1669
|
+
"displayId": Int(did), "bounds": rectJSON(bounds), "capturedPixels": ["w": px.w, "h": px.h],
|
|
1670
|
+
"image": try imageJSON(img, quality: a.num("quality") ?? 0.8),
|
|
1671
|
+
"cursor": pointJSON(cursorLocation()), "frontmost": frontmostJSON(), "capturedAt": epochMs(),
|
|
1672
|
+
]
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/// A region at native resolution, downscaled only to stay within fit. The rect is
|
|
1676
|
+
/// clipped to the display under its centre.
|
|
1677
|
+
func cmdCaptureRect(_ a: Args) throws -> Any {
|
|
1678
|
+
try requireCapture()
|
|
1679
|
+
let rect = try Args.rect(a.dict("rect"), "rect")
|
|
1680
|
+
guard let did = Displays.containing(CGPoint(x: rect.midX, y: rect.midY)) else {
|
|
1681
|
+
throw HelperError("OUT_OF_BOUNDS", "the rect's centre is not on any display", ["rect": rectJSON(rect)])
|
|
1682
|
+
}
|
|
1683
|
+
let dbounds = CGDisplayBounds(did)
|
|
1684
|
+
let clipped = rect.intersection(dbounds)
|
|
1685
|
+
guard !clipped.isNull, clipped.width >= 1, clipped.height >= 1 else {
|
|
1686
|
+
throw HelperError("OUT_OF_BOUNDS", "the rect does not overlap a display", ["rect": rectJSON(rect)])
|
|
1687
|
+
}
|
|
1688
|
+
let scale = Displays.scale(did)
|
|
1689
|
+
let fit = parseFit(a, defaultW: Double(clipped.width) * scale, defaultH: Double(clipped.height) * scale)
|
|
1690
|
+
let size = fitSize(width: Double(clipped.width), height: Double(clipped.height), fitW: fit.w, fitH: fit.h, scale: scale)
|
|
1691
|
+
var img: CGImage
|
|
1692
|
+
if #available(macOS 15.2, *) {
|
|
1693
|
+
do {
|
|
1694
|
+
img = try waitFor("ScreenCaptureKit", timeout: 10) { done in
|
|
1695
|
+
SCScreenshotManager.captureImage(in: clipped) { i, e in done(i, e) }
|
|
1696
|
+
}
|
|
1697
|
+
} catch { throw mapCaptureError(error) }
|
|
1698
|
+
} else {
|
|
1699
|
+
let display = try shareableDisplay(did)
|
|
1700
|
+
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
1701
|
+
let cfg = SCStreamConfiguration()
|
|
1702
|
+
cfg.sourceRect = clipped.offsetBy(dx: -dbounds.minX, dy: -dbounds.minY) // display-local points
|
|
1703
|
+
cfg.width = size.w
|
|
1704
|
+
cfg.height = size.h
|
|
1705
|
+
cfg.showsCursor = false
|
|
1706
|
+
cfg.capturesAudio = false
|
|
1707
|
+
cfg.pixelFormat = kCVPixelFormatType_32BGRA
|
|
1708
|
+
cfg.colorSpaceName = CGColorSpace.sRGB
|
|
1709
|
+
cfg.captureResolution = .best
|
|
1710
|
+
img = try sckCapture(filter, cfg)
|
|
1711
|
+
}
|
|
1712
|
+
if img.width != size.w || img.height != size.h, let scaled = scaleImage(img, size.w, size.h) { img = scaled }
|
|
1713
|
+
return ["image": try imageJSON(img, quality: a.num("quality") ?? 0.85), "rect": rectJSON(clipped), "capturedAt": epochMs()]
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// MARK: - Pointer / keyboard commands
|
|
1717
|
+
|
|
1718
|
+
func cmdMove(_ a: Args) throws -> Any {
|
|
1719
|
+
try preInput()
|
|
1720
|
+
let p = try a.point()
|
|
1721
|
+
try requireOnScreen(p)
|
|
1722
|
+
// With a button held (mouse_down) a move is a drag: the drop target is guarded.
|
|
1723
|
+
if Input.shared.heldButton != nil { _ = try guardPoint(p, context: "move.drag") }
|
|
1724
|
+
try Input.shared.move(to: p, steps: a.int("steps") ?? Config.input.moveSteps, gen: Input.shared.generation)
|
|
1725
|
+
return [String: Any]()
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
func cmdClick(_ a: Args) throws -> Any {
|
|
1729
|
+
try preInput()
|
|
1730
|
+
let p = try a.point()
|
|
1731
|
+
try requireOnScreen(p)
|
|
1732
|
+
let b = try parseButton(a.string("button"))
|
|
1733
|
+
let count = clampInt(a.int("count") ?? 1, 1, 3)
|
|
1734
|
+
let flags = try parseModifiers(a.array("modifiers"))
|
|
1735
|
+
let g = Input.shared.generation
|
|
1736
|
+
_ = try guardPoint(p, context: "click") // refuse before the pointer moves…
|
|
1737
|
+
try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
|
|
1738
|
+
try requireUnlocked()
|
|
1739
|
+
let target = try guardPoint(p, context: "click") // …and again right before the button goes down
|
|
1740
|
+
try Input.shared.checkAbort(g)
|
|
1741
|
+
try Input.shared.click(at: p, button: b, count: count, flags: flags, gen: g)
|
|
1742
|
+
return ["target": target.json]
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
func cmdMouseDown(_ a: Args) throws -> Any {
|
|
1746
|
+
try preInput()
|
|
1747
|
+
let p = try a.point()
|
|
1748
|
+
try requireOnScreen(p)
|
|
1749
|
+
let b = try parseButton(a.string("button"))
|
|
1750
|
+
let g = Input.shared.generation
|
|
1751
|
+
_ = try guardPoint(p, context: "mouse_down")
|
|
1752
|
+
try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
|
|
1753
|
+
let target = try guardPoint(p, context: "mouse_down")
|
|
1754
|
+
try Input.shared.checkAbort(g)
|
|
1755
|
+
Input.shared.button(b, down: true, at: p)
|
|
1756
|
+
return ["target": target.json]
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/// No guard: refusing a release would leave the button stuck.
|
|
1760
|
+
func cmdMouseUp(_ a: Args) throws -> Any {
|
|
1761
|
+
try requireTrusted()
|
|
1762
|
+
let p = try a.point()
|
|
1763
|
+
try requireOnScreen(p)
|
|
1764
|
+
let b = try parseButton(a.string("button"))
|
|
1765
|
+
Input.shared.postPointer(p)
|
|
1766
|
+
sleepMs(8)
|
|
1767
|
+
let target = (try? probeAt(p)) ?? Probe()
|
|
1768
|
+
Input.shared.button(b, down: false, at: p)
|
|
1769
|
+
return ["target": target.json]
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
func cmdDrag(_ a: Args) throws -> Any {
|
|
1773
|
+
try preInput()
|
|
1774
|
+
let from = try Args.point(a.dict("from"), "from")
|
|
1775
|
+
let to = try Args.point(a.dict("to"), "to")
|
|
1776
|
+
try requireOnScreen(from)
|
|
1777
|
+
try requireOnScreen(to)
|
|
1778
|
+
let b = try parseButton(a.string("button"))
|
|
1779
|
+
let steps = clampInt(a.int("steps") ?? 20, 1, 400)
|
|
1780
|
+
let g = Input.shared.generation
|
|
1781
|
+
_ = try guardPoint(from, context: "drag.from")
|
|
1782
|
+
_ = try guardPoint(to, context: "drag.to")
|
|
1783
|
+
try Input.shared.move(to: from, steps: Config.input.moveSteps, gen: g)
|
|
1784
|
+
let target = try guardPoint(from, context: "drag.from")
|
|
1785
|
+
try Input.shared.checkAbort(g)
|
|
1786
|
+
Input.shared.button(b, down: true, at: from)
|
|
1787
|
+
do {
|
|
1788
|
+
sleepMs(50)
|
|
1789
|
+
for i in 1...steps {
|
|
1790
|
+
try Input.shared.checkAbort(g)
|
|
1791
|
+
let t = CGFloat(i) / CGFloat(steps)
|
|
1792
|
+
Input.shared.postPointer(CGPoint(x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t))
|
|
1793
|
+
sleepMs(8)
|
|
1794
|
+
}
|
|
1795
|
+
sleepMs(30)
|
|
1796
|
+
// The drop target could have changed while dragging: cancel back at the origin.
|
|
1797
|
+
if let e = matchGuard(try probeAt(to), Config.guardCfg, context: "drag.to") {
|
|
1798
|
+
Input.shared.postPointer(from)
|
|
1799
|
+
sleepMs(20)
|
|
1800
|
+
Input.shared.button(b, down: false, at: from)
|
|
1801
|
+
throw e
|
|
1802
|
+
}
|
|
1803
|
+
Input.shared.button(b, down: false, at: to)
|
|
1804
|
+
} catch {
|
|
1805
|
+
if Input.shared.isHeld(b) { Input.shared.button(b, down: false, at: cursorLocation()) }
|
|
1806
|
+
throw error
|
|
1807
|
+
}
|
|
1808
|
+
return ["target": target.json]
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
func cmdScroll(_ a: Args) throws -> Any {
|
|
1812
|
+
try preInput()
|
|
1813
|
+
let p = try a.point()
|
|
1814
|
+
try requireOnScreen(p)
|
|
1815
|
+
if let u = a.string("units"), u != "line" { throw HelperError("BAD_ARGS", "units must be 'line'") }
|
|
1816
|
+
let dx = clampInt(a.int("dx") ?? 0, -1000, 1000), dy = clampInt(a.int("dy") ?? 0, -1000, 1000)
|
|
1817
|
+
let flags = try parseModifiers(a.array("modifiers"))
|
|
1818
|
+
let g = Input.shared.generation
|
|
1819
|
+
_ = try guardPoint(p, context: "scroll")
|
|
1820
|
+
try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
|
|
1821
|
+
let target = try guardPoint(p, context: "scroll")
|
|
1822
|
+
try Input.shared.scroll(at: p, dx: dx, dy: dy, flags: flags, gen: g)
|
|
1823
|
+
return ["target": target.json]
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
func cmdType(_ a: Args) throws -> Any {
|
|
1827
|
+
try preInput()
|
|
1828
|
+
guard let text = a.string("text") else { throw HelperError("BAD_ARGS", "text is required") }
|
|
1829
|
+
let mode = a.string("mode") ?? "unicode"
|
|
1830
|
+
guard mode == "unicode" || mode == "keys" else { throw HelperError("BAD_ARGS", "mode must be 'unicode' or 'keys'") }
|
|
1831
|
+
let cfg = Config.input
|
|
1832
|
+
let g = Input.shared.generation
|
|
1833
|
+
let started = nowNs()
|
|
1834
|
+
_ = try keyboardGuard(printable: true, context: "type")
|
|
1835
|
+
let layout: LayoutMap = mode == "keys" ? currentLayoutMap() : [:]
|
|
1836
|
+
var typed = 0 // UTF-16 units, so the caller can `text.slice(typed)` what's left
|
|
1837
|
+
for (i, seg) in segmentText(text, chunk: cfg.typeChunk).enumerated() {
|
|
1838
|
+
if Input.shared.generation != g { return ["typed": typed, "interrupted": "abort"] }
|
|
1839
|
+
if Input.shared.userInterrupted(since: started) { return ["typed": typed, "interrupted": "user_input"] }
|
|
1840
|
+
if i > 0 {
|
|
1841
|
+
// Focus can move mid-text (a Tab into a password field): re-check every chunk.
|
|
1842
|
+
do { _ = try keyboardGuard(printable: true, context: "type") } catch let e as HelperError { throw e.adding(["typed": typed]) }
|
|
1843
|
+
}
|
|
1844
|
+
switch seg {
|
|
1845
|
+
case .text(let s):
|
|
1846
|
+
if mode == "keys" { Input.shared.typeKeys(s, layout: layout) } else { Input.shared.typeUnicode(s) }
|
|
1847
|
+
case .key(let code, _):
|
|
1848
|
+
Input.shared.tap(code)
|
|
1849
|
+
}
|
|
1850
|
+
typed += seg.utf16Count
|
|
1851
|
+
sleepMs(cfg.typeDelayMs)
|
|
1852
|
+
}
|
|
1853
|
+
return ["typed": typed]
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
func cmdKey(_ a: Args, hold: Bool) throws -> Any {
|
|
1857
|
+
try preInput()
|
|
1858
|
+
guard let raw = a.string("combo") else { throw HelperError("BAD_ARGS", "combo is required") }
|
|
1859
|
+
let combo = try parseCombo(raw, layout: currentLayoutMap())
|
|
1860
|
+
let holdMs = hold ? clampDouble(a.num("durationMs") ?? 500, 0, 30_000) : 0
|
|
1861
|
+
let target = try keyboardGuard(printable: combo.needsSecureCheck, context: hold ? "hold_key" : "key")
|
|
1862
|
+
try Input.shared.pressCombo(combo, repeatCount: hold ? 1 : (a.int("repeat") ?? 1), holdMs: holdMs, gen: Input.shared.generation)
|
|
1863
|
+
return ["target": target.json]
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// MARK: - Apps and windows
|
|
1867
|
+
|
|
1868
|
+
func cmdApps() -> Any {
|
|
1869
|
+
NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular }.map { app -> [String: Any] in
|
|
1870
|
+
[
|
|
1871
|
+
"pid": Int(app.processIdentifier), "bundleId": app.bundleIdentifier as Any, "name": app.localizedName as Any,
|
|
1872
|
+
"active": app.isActive, "hidden": app.isHidden, "path": app.bundleURL?.path as Any,
|
|
1873
|
+
]
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
func cmdWindows(_ a: Args) -> Any {
|
|
1878
|
+
let pidFilter = a.int("pid")
|
|
1879
|
+
var cache: [Int: (String?, String?)] = [:]
|
|
1880
|
+
return Windows.list(onScreenOnly: a.bool("onScreenOnly") ?? true)
|
|
1881
|
+
.filter { pidFilter == nil || $0.pid == pidFilter! }
|
|
1882
|
+
.map { w -> [String: Any] in
|
|
1883
|
+
if cache[w.pid] == nil { let i = appInfo(pid_t(w.pid)); cache[w.pid] = (i.bundleId, i.name) }
|
|
1884
|
+
let info = cache[w.pid]!
|
|
1885
|
+
let appName: String? = info.1 ?? w.owner
|
|
1886
|
+
return [
|
|
1887
|
+
"windowId": w.windowId, "pid": w.pid, "app": appName as Any, "bundleId": info.0 as Any,
|
|
1888
|
+
"title": w.title as Any, "bounds": rectJSON(w.bounds), "layer": w.layer, "onScreen": w.onScreen,
|
|
1889
|
+
]
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/// Names and bundle ids only — never URLs, paths or anything a shell would expand.
|
|
1894
|
+
func validateAppToken(_ s: String, field: String) throws {
|
|
1895
|
+
let lower = s.lowercased()
|
|
1896
|
+
if s.isEmpty || s.count > 200 || lower.contains("://") || s.contains("/") || s.contains(":") || s.contains("..")
|
|
1897
|
+
|| s.hasPrefix("~") || s.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) {
|
|
1898
|
+
throw HelperError("BAD_ARGS", "\(field) must be an app name or bundle id, not a URL or path")
|
|
1899
|
+
}
|
|
1900
|
+
if lower.range(of: "^[a-z][a-z0-9+.-]*:", options: .regularExpression) != nil {
|
|
1901
|
+
throw HelperError("BAD_ARGS", "\(field) looks like a URL")
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
func resolveAppURL(bundleId: String?, name: String?) -> URL? {
|
|
1906
|
+
if let b = bundleId { return NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) }
|
|
1907
|
+
guard let n = name else { return nil }
|
|
1908
|
+
if let running = NSWorkspace.shared.runningApplications.first(where: { ($0.localizedName ?? "").caseInsensitiveCompare(n) == .orderedSame }),
|
|
1909
|
+
let url = running.bundleURL {
|
|
1910
|
+
return url
|
|
1911
|
+
}
|
|
1912
|
+
let fm = FileManager.default
|
|
1913
|
+
let dirs = ["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities",
|
|
1914
|
+
NSHomeDirectory() + "/Applications", "/System/Library/CoreServices"]
|
|
1915
|
+
let wanted = "\(n).app"
|
|
1916
|
+
for d in dirs where fm.fileExists(atPath: "\(d)/\(wanted)") { return URL(fileURLWithPath: "\(d)/\(wanted)") }
|
|
1917
|
+
for d in dirs {
|
|
1918
|
+
if let items = try? fm.contentsOfDirectory(atPath: d), let hit = items.first(where: { $0.caseInsensitiveCompare(wanted) == .orderedSame }) {
|
|
1919
|
+
return URL(fileURLWithPath: "\(d)/\(hit)")
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return nil
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
func cmdOpenApp(_ a: Args) throws -> Any {
|
|
1926
|
+
try requireNotLatched()
|
|
1927
|
+
try requireUnlocked()
|
|
1928
|
+
let bundleId = a.string("bundleId"), name = a.string("name")
|
|
1929
|
+
guard bundleId != nil || name != nil else { throw HelperError("BAD_ARGS", "bundleId or name is required") }
|
|
1930
|
+
if let b = bundleId { try validateAppToken(b, field: "bundleId") }
|
|
1931
|
+
if let n = name { try validateAppToken(n, field: "name") }
|
|
1932
|
+
guard let url = resolveAppURL(bundleId: bundleId, name: name) else {
|
|
1933
|
+
throw HelperError("APP_NOT_FOUND", "no application \(bundleId ?? name ?? "")", ["bundleId": bundleId as Any, "name": name as Any])
|
|
1934
|
+
}
|
|
1935
|
+
let bundle = Bundle(url: url)
|
|
1936
|
+
var probe = Probe()
|
|
1937
|
+
probe.bundleId = bundle?.bundleIdentifier ?? bundleId
|
|
1938
|
+
probe.app = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
|
|
1939
|
+
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? url.deletingPathExtension().lastPathComponent
|
|
1940
|
+
if let e = matchGuard(probe, Config.guardCfg, context: "open_app") { throw e }
|
|
1941
|
+
let cfg = NSWorkspace.OpenConfiguration()
|
|
1942
|
+
cfg.activates = true
|
|
1943
|
+
let app: NSRunningApplication = try waitFor("open_app", timeout: 20) { done in
|
|
1944
|
+
NSWorkspace.shared.openApplication(at: url, configuration: cfg) { app, e in done(app, e) }
|
|
1945
|
+
}
|
|
1946
|
+
let bundleIdOut: String? = app.bundleIdentifier ?? probe.bundleId
|
|
1947
|
+
return ["pid": Int(app.processIdentifier), "bundleId": bundleIdOut as Any]
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
func axWindow(pid: pid_t, windowId: Int) -> AXUIElement? {
|
|
1951
|
+
let axApp = AXUIElementCreateApplication(pid)
|
|
1952
|
+
let (e, v) = AX.copy(axApp, "AXWindows")
|
|
1953
|
+
guard e == .success, let arr = v as? [AnyObject] else { return nil }
|
|
1954
|
+
for item in arr {
|
|
1955
|
+
if let win = AX.element(item), windowID(of: win, pid: pid) == windowId { return win }
|
|
1956
|
+
}
|
|
1957
|
+
return nil
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
func cmdFocusApp(_ a: Args) throws -> Any {
|
|
1961
|
+
try requireNotLatched()
|
|
1962
|
+
try requireUnlocked()
|
|
1963
|
+
let windowId = a.int("windowId")
|
|
1964
|
+
var app: NSRunningApplication? = nil
|
|
1965
|
+
if let pid = a.int("pid") {
|
|
1966
|
+
app = NSRunningApplication(processIdentifier: pid_t(clamping: pid))
|
|
1967
|
+
} else if let b = a.string("bundleId") {
|
|
1968
|
+
app = NSRunningApplication.runningApplications(withBundleIdentifier: b).first
|
|
1969
|
+
} else if let wid = windowId, let w = Windows.info(wid) {
|
|
1970
|
+
app = NSRunningApplication(processIdentifier: pid_t(w.pid))
|
|
1971
|
+
}
|
|
1972
|
+
guard let target = app else { throw HelperError("APP_NOT_FOUND", "no running application matches", a.raw) }
|
|
1973
|
+
var probe = Probe()
|
|
1974
|
+
probe.pid = Int(target.processIdentifier)
|
|
1975
|
+
probe.bundleId = target.bundleIdentifier
|
|
1976
|
+
probe.app = target.localizedName
|
|
1977
|
+
if let wid = windowId { probe.windowId = wid; probe.windowTitle = Windows.info(wid)?.title }
|
|
1978
|
+
if let e = matchGuard(probe, Config.guardCfg, context: "focus_app") { throw e }
|
|
1979
|
+
onMain {
|
|
1980
|
+
_ = target.unhide()
|
|
1981
|
+
_ = target.activate(options: [.activateAllWindows])
|
|
1982
|
+
}
|
|
1983
|
+
if AXIsProcessTrusted() {
|
|
1984
|
+
AX.prepare()
|
|
1985
|
+
let axApp = AXUIElementCreateApplication(target.processIdentifier)
|
|
1986
|
+
_ = AX.setBool(axApp, "AXFrontmost", true) // works for background callers under cooperative activation
|
|
1987
|
+
if let wid = windowId, let win = axWindow(pid: target.processIdentifier, windowId: wid) {
|
|
1988
|
+
_ = AX.perform(win, "AXRaise")
|
|
1989
|
+
_ = AX.setBool(win, "AXMain", true)
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
let deadline = nowNs() + 1_000_000_000
|
|
1993
|
+
while nowNs() < deadline && NSWorkspace.shared.frontmostApplication?.processIdentifier != target.processIdentifier { sleepMs(50) }
|
|
1994
|
+
return [
|
|
1995
|
+
"pid": Int(target.processIdentifier), "bundleId": target.bundleIdentifier as Any,
|
|
1996
|
+
"frontmost": NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier,
|
|
1997
|
+
]
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// MARK: - AX snapshot / action
|
|
2001
|
+
|
|
2002
|
+
enum Snapshots {
|
|
2003
|
+
/// Last 4 snapshots, each holding its retained elements. Only the action queue touches this.
|
|
2004
|
+
private static var items: [(id: String, elements: [AXUIElement])] = []
|
|
2005
|
+
private static var counter = 0
|
|
2006
|
+
|
|
2007
|
+
static func add(_ elements: [AXUIElement]) -> String {
|
|
2008
|
+
counter += 1
|
|
2009
|
+
let id = "s\(counter)"
|
|
2010
|
+
items.append((id, elements))
|
|
2011
|
+
if items.count > 4 { items.removeFirst(items.count - 4) }
|
|
2012
|
+
return id
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
static func element(_ snapshotId: String, _ ref: String) -> AXUIElement? {
|
|
2016
|
+
guard let snap = items.first(where: { $0.id == snapshotId }), ref.hasPrefix("e"), let n = Int(ref.dropFirst()),
|
|
2017
|
+
n >= 1, n <= snap.elements.count else { return nil }
|
|
2018
|
+
return snap.elements[n - 1]
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
let INTERACTIVE_ROLES: Set<String> = [
|
|
2023
|
+
"AXButton", "AXCheckBox", "AXRadioButton", "AXTextField", "AXTextArea", "AXComboBox", "AXPopUpButton",
|
|
2024
|
+
"AXMenuButton", "AXSlider", "AXLink", "AXMenuItem", "AXMenuBarItem", "AXDisclosureTriangle", "AXIncrementor",
|
|
2025
|
+
"AXColorWell", "AXDockItem", "AXRow", "AXTab", "AXSearchField",
|
|
2026
|
+
]
|
|
2027
|
+
let INTERACTIVE_ACTIONS: Set<String> = ["AXPress", "AXConfirm", "AXPick", "AXIncrement", "AXDecrement", "AXShowMenu", "AXOpen"]
|
|
2028
|
+
let ACTION_NAMES: [String: String] = [
|
|
2029
|
+
"AXPress": "press", "AXShowMenu": "show_menu", "AXRaise": "raise", "AXScrollToVisible": "scroll_into_view",
|
|
2030
|
+
"AXConfirm": "confirm", "AXPick": "select", "AXIncrement": "increment", "AXDecrement": "decrement", "AXCancel": "cancel",
|
|
2031
|
+
]
|
|
2032
|
+
/// One multi-attribute call per element. 0-7 since protocol 1, 8-12 since protocol 2.
|
|
2033
|
+
let SNAPSHOT_ATTRS = [
|
|
2034
|
+
"AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXEnabled", "AXFocused", "AXPosition", "AXSize",
|
|
2035
|
+
"AXHelp", "AXPlaceholderValue", "AXIdentifier", "AXTitleUIElement", "AXModal",
|
|
2036
|
+
]
|
|
2037
|
+
/// Labelled containers name the `group` of everything below them.
|
|
2038
|
+
let GROUP_ROLES: Set<String> = [
|
|
2039
|
+
"AXGroup", "AXToolbar", "AXSheet", "AXTabGroup", "AXSplitGroup", "AXScrollArea", "AXOutline", "AXTable",
|
|
2040
|
+
"AXList", "AXRadioGroup", "AXPopover", "AXBrowser",
|
|
2041
|
+
]
|
|
2042
|
+
/// These name the group even without a label ("toolbar", "sheet", "popover").
|
|
2043
|
+
let BARE_GROUP_ROLES: Set<String> = ["AXToolbar", "AXSheet", "AXPopover"]
|
|
2044
|
+
let DIALOG_SUBROLES: Set<String> = ["AXDialog", "AXSystemDialog"]
|
|
2045
|
+
/// Unnamed rows and cells (and AXOutlineRow subroles) get a contentLabel with enrich.
|
|
2046
|
+
let ROW_ROLES: Set<String> = ["AXRow", "AXCell"]
|
|
2047
|
+
|
|
2048
|
+
/// Whitespace runs (newlines included) collapsed to one space; nil when blank.
|
|
2049
|
+
func cleanLabel(_ s: String?) -> String? {
|
|
2050
|
+
guard let s = s else { return nil }
|
|
2051
|
+
let out = s.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ")
|
|
2052
|
+
return out.isEmpty ? nil : out
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/// The `group` handed to an element's children: "<role> <label>" (role without
|
|
2056
|
+
/// AX, lowercased; clipped to 60) for a labelled container, the bare role for an
|
|
2057
|
+
/// unlabelled toolbar / sheet / popover, else the inherited group. A window is
|
|
2058
|
+
/// never a group, so window titles never leak through this field.
|
|
2059
|
+
func groupForChildren(role: String?, title: String?, desc: String?, inherited: String?) -> String? {
|
|
2060
|
+
guard let r = role, GROUP_ROLES.contains(r) else { return inherited }
|
|
2061
|
+
let name = String(r.dropFirst(2)).lowercased()
|
|
2062
|
+
if let label = cleanLabel(title) ?? cleanLabel(desc) { return String("\(name) \(label)".prefix(60)) }
|
|
2063
|
+
return BARE_GROUP_ROLES.contains(r) ? name : inherited
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/// enrich: the label of an element's AXTitleUIElement (its AXValue, else AXTitle), ≤ 80.
|
|
2067
|
+
func titleElementLabel(_ el: AXUIElement?) -> String? {
|
|
2068
|
+
guard let el = el else { return nil }
|
|
2069
|
+
let (e, v) = AX.multi(el, ["AXValue", "AXTitle"])
|
|
2070
|
+
guard e == .success else { return nil }
|
|
2071
|
+
return (cleanLabel(v[0] as? String) ?? cleanLabel(v[1] as? String)).map { String($0.prefix(80)) }
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/// enrich: an unnamed row / cell is named by the first AXStaticText value within
|
|
2075
|
+
/// two levels below it (breadth first, ≤ 8 children per element), ≤ 80.
|
|
2076
|
+
func contentLabel(_ el: AXUIElement, deadline: UInt64) -> String? {
|
|
2077
|
+
var level = AX.children(el, limit: 8)
|
|
2078
|
+
for depth in 0..<2 {
|
|
2079
|
+
var next: [AXUIElement] = []
|
|
2080
|
+
for c in level {
|
|
2081
|
+
if nowNs() > deadline { return nil }
|
|
2082
|
+
guard let r = AX.string(c, "AXRole") else { continue }
|
|
2083
|
+
if r == "AXStaticText", let s = cleanLabel(AX.string(c, "AXValue")) { return String(s.prefix(80)) }
|
|
2084
|
+
if depth == 0 { next.append(contentsOf: AX.children(c, limit: 8)) }
|
|
2085
|
+
}
|
|
2086
|
+
level = next
|
|
2087
|
+
}
|
|
2088
|
+
return nil
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/// enrich: the snapshot's root window as {id, title, subrole, modal}, or null.
|
|
2092
|
+
func rootWindowJSON(_ root: AXUIElement?, pid: pid_t) -> Any {
|
|
2093
|
+
guard let w = root else { return NSNull() }
|
|
2094
|
+
let (e, v) = AX.multi(w, ["AXTitle", "AXSubrole", "AXModal"])
|
|
2095
|
+
guard e == .success else { return NSNull() }
|
|
2096
|
+
return [
|
|
2097
|
+
"id": windowID(of: w, pid: pid) as Any, "title": trimmed(v[0] as? String, 1000) as Any,
|
|
2098
|
+
"subrole": (v[1] as? String) as Any, "modal": (v[2] as? NSNumber)?.boolValue ?? false,
|
|
2099
|
+
]
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/// The app's UI language as a primary subtag ("pt"): the first of the bundle's
|
|
2103
|
+
/// localizations in the user's preference order ("Base" skipped; legacy names
|
|
2104
|
+
/// such as "English" canonicalized). nil when unknown.
|
|
2105
|
+
func appLanguage(_ running: NSRunningApplication) -> String? {
|
|
2106
|
+
guard let url = running.bundleURL, let bundle = Bundle(url: url) else { return nil }
|
|
2107
|
+
for loc in bundle.preferredLocalizations + [bundle.developmentLocalization].compactMap({ $0 }) where loc.lowercased() != "base" {
|
|
2108
|
+
let canonical = Locale.canonicalLanguageIdentifier(from: loc)
|
|
2109
|
+
if let primary = canonical.split(whereSeparator: { $0 == "-" || $0 == "_" }).first, !primary.isEmpty {
|
|
2110
|
+
return primary.lowercased()
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return nil
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/// One pending element of the snapshot walk, with what it inherits from above.
|
|
2117
|
+
struct AXVisit {
|
|
2118
|
+
let el: AXUIElement
|
|
2119
|
+
let depth: Int
|
|
2120
|
+
let parent: String?
|
|
2121
|
+
let group: String?
|
|
2122
|
+
let modal: Bool
|
|
2123
|
+
let web: Bool
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
func cmdAxSnapshot(_ a: Args) throws -> Any {
|
|
2127
|
+
try requireTrusted()
|
|
2128
|
+
try requireUnlocked()
|
|
2129
|
+
AX.prepare()
|
|
2130
|
+
let depthMax = clampInt(a.int("depth") ?? 12, 1, 40)
|
|
2131
|
+
let maxNodes = clampInt(a.int("maxNodes") ?? 400, 1, 5000)
|
|
2132
|
+
let interactiveOnly = a.bool("interactiveOnly") ?? true
|
|
2133
|
+
let timeoutMs = clampDouble(a.num("timeoutMs") ?? 4000, 100, 30_000)
|
|
2134
|
+
// Intent snapshots: titleElement / contentLabel per node; window, app.lang and
|
|
2135
|
+
// up to maxTexts static texts (≤ 200 chars each) on the result.
|
|
2136
|
+
let enrich = a.bool("enrich") ?? false
|
|
2137
|
+
let maxTexts = clampInt(a.int("maxTexts") ?? 80, 0, 500)
|
|
2138
|
+
|
|
2139
|
+
// Target: an explicit window, else the given (or frontmost) app's focused window.
|
|
2140
|
+
var pid: pid_t
|
|
2141
|
+
var root: AXUIElement? = nil
|
|
2142
|
+
if let wid = a.int("windowId") {
|
|
2143
|
+
guard let w = Windows.info(wid) else { throw HelperError("APP_NOT_FOUND", "window \(wid) not found", ["windowId": wid]) }
|
|
2144
|
+
pid = pid_t(w.pid)
|
|
2145
|
+
root = axWindow(pid: pid, windowId: wid)
|
|
2146
|
+
if root == nil { throw HelperError("AX_ERROR", "window \(wid) is not exposed to accessibility", ["windowId": wid]) }
|
|
2147
|
+
} else if let p = a.int("pid") {
|
|
2148
|
+
pid = pid_t(clamping: p)
|
|
2149
|
+
} else {
|
|
2150
|
+
guard let front = NSWorkspace.shared.frontmostApplication else { throw HelperError("APP_NOT_FOUND", "no frontmost application") }
|
|
2151
|
+
pid = front.processIdentifier
|
|
2152
|
+
}
|
|
2153
|
+
guard let running = NSRunningApplication(processIdentifier: pid) else { throw HelperError("APP_NOT_FOUND", "no process \(pid)") }
|
|
2154
|
+
let appEl = AXUIElementCreateApplication(pid)
|
|
2155
|
+
AXUIElementSetMessagingTimeout(appEl, Float(min(1.0, timeoutMs / 1000)))
|
|
2156
|
+
if root == nil {
|
|
2157
|
+
for attr in ["AXFocusedWindow", "AXMainWindow"] {
|
|
2158
|
+
let (e, v) = AX.copy(appEl, attr)
|
|
2159
|
+
if e == .cannotComplete { throw axFail(e, "ax_snapshot") }
|
|
2160
|
+
if let w = AX.element(v) { root = w; break }
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
let start = root ?? appEl
|
|
2164
|
+
let deadline = nowNs() + UInt64(timeoutMs * 1_000_000)
|
|
2165
|
+
|
|
2166
|
+
var nodes: [[String: Any]] = []
|
|
2167
|
+
var elements: [AXUIElement] = []
|
|
2168
|
+
var texts: [String] = []
|
|
2169
|
+
var truncated = false
|
|
2170
|
+
var visited = 0
|
|
2171
|
+
var stack: [AXVisit] = [AXVisit(el: start, depth: 0, parent: nil, group: nil, modal: false, web: false)]
|
|
2172
|
+
while let item = stack.popLast() {
|
|
2173
|
+
if nodes.count >= maxNodes || nowNs() > deadline || visited >= maxNodes * 25 { truncated = true; break }
|
|
2174
|
+
visited += 1
|
|
2175
|
+
autoreleasepool {
|
|
2176
|
+
let (err, vals) = AX.multi(item.el, SNAPSHOT_ATTRS)
|
|
2177
|
+
if err != .success {
|
|
2178
|
+
if visited == 1 && err == .cannotComplete { truncated = true }
|
|
2179
|
+
return
|
|
2180
|
+
}
|
|
2181
|
+
let role = vals[0] as? String
|
|
2182
|
+
let subrole = vals[1] as? String
|
|
2183
|
+
let title = trimmed(vals[2] as? String, 200)
|
|
2184
|
+
let desc = trimmed(vals[3] as? String, 200)
|
|
2185
|
+
let secure = subrole == "AXSecureTextField" || role == "AXSecureTextField"
|
|
2186
|
+
// modal: at or below a sheet, a popover, a dialog window or anything AXModal.
|
|
2187
|
+
// web: at or below a web area (page content, never pressed by intent).
|
|
2188
|
+
let dialogWindow = role == "AXWindow" && (subrole.map { DIALOG_SUBROLES.contains($0) } ?? false)
|
|
2189
|
+
let modal = item.modal || role == "AXSheet" || role == "AXPopover" || dialogWindow
|
|
2190
|
+
|| ((vals[12] as? NSNumber)?.boolValue ?? false)
|
|
2191
|
+
let web = item.web || role == "AXWebArea"
|
|
2192
|
+
if enrich && role == "AXStaticText" && texts.count < maxTexts, let s = cleanLabel(AX.string(item.el, "AXValue")) {
|
|
2193
|
+
texts.append(String(s.prefix(200)))
|
|
2194
|
+
}
|
|
2195
|
+
let rawActions = AX.actions(item.el)
|
|
2196
|
+
let interactive = secure || (role.map { INTERACTIVE_ROLES.contains($0) } ?? false)
|
|
2197
|
+
|| rawActions.contains(where: { INTERACTIVE_ACTIONS.contains($0) })
|
|
2198
|
+
var myRef = item.parent
|
|
2199
|
+
if !interactiveOnly || interactive {
|
|
2200
|
+
elements.append(item.el)
|
|
2201
|
+
let ref = "e\(elements.count)"
|
|
2202
|
+
myRef = ref
|
|
2203
|
+
var frame: Any = NSNull()
|
|
2204
|
+
if let p = AX.point(vals[6]), let s = AX.size(vals[7]) { frame = rectJSON(CGRect(origin: p, size: s)) }
|
|
2205
|
+
let textRole = role.map { TEXT_ROLES.contains($0) || $0 == "AXSearchField" } ?? false
|
|
2206
|
+
var actions = rawActions.compactMap { ACTION_NAMES[$0] ?? ($0.hasPrefix("AX") ? nil : $0) }
|
|
2207
|
+
if textRole { actions += ["focus", "set_value"] }
|
|
2208
|
+
if role == "AXCheckBox" || role == "AXRadioButton" { actions.append("toggle") }
|
|
2209
|
+
var node: [String: Any] = [
|
|
2210
|
+
"ref": ref, "parent": item.parent as Any, "depth": item.depth, "role": role as Any, "subrole": subrole as Any,
|
|
2211
|
+
"title": title as Any, "description": desc as Any,
|
|
2212
|
+
"value": (secure ? nil : readValue(item.el, role: role, limit: 200)) as Any, "secure": secure,
|
|
2213
|
+
"enabled": (vals[4] as? NSNumber)?.boolValue as Any, "focused": (vals[5] as? NSNumber)?.boolValue as Any,
|
|
2214
|
+
"actions": Array(Set(actions)).sorted(), "frame": frame,
|
|
2215
|
+
"help": trimmed(vals[8] as? String, 200) as Any,
|
|
2216
|
+
"placeholder": (textRole ? trimmed(vals[9] as? String, 200) : nil) as Any,
|
|
2217
|
+
"identifier": trimmed(vals[10] as? String, 120) as Any,
|
|
2218
|
+
"group": item.group as Any, "modal": modal, "web": web,
|
|
2219
|
+
]
|
|
2220
|
+
if enrich {
|
|
2221
|
+
node["titleElement"] = titleElementLabel(AX.element(vals[11])) as Any
|
|
2222
|
+
let rowLike = (role.map { ROW_ROLES.contains($0) } ?? false) || subrole == "AXOutlineRow"
|
|
2223
|
+
let unnamed = cleanLabel(title) == nil && cleanLabel(desc) == nil
|
|
2224
|
+
node["contentLabel"] = (rowLike && unnamed ? contentLabel(item.el, deadline: deadline) : nil) as Any
|
|
2225
|
+
}
|
|
2226
|
+
nodes.append(node)
|
|
2227
|
+
}
|
|
2228
|
+
if item.depth < depthMax && role != "AXMenuBar" {
|
|
2229
|
+
let kids = AX.children(item.el, limit: 500)
|
|
2230
|
+
if kids.count >= 500 { truncated = true }
|
|
2231
|
+
let group = groupForChildren(role: role, title: title, desc: desc, inherited: item.group)
|
|
2232
|
+
for c in kids.reversed() {
|
|
2233
|
+
stack.append(AXVisit(el: c, depth: item.depth + 1, parent: myRef, group: group, modal: modal, web: web))
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
if nodes.isEmpty && visited <= 1 && truncated {
|
|
2239
|
+
throw HelperError("AX_TIMEOUT", "the application did not answer accessibility queries in time")
|
|
2240
|
+
}
|
|
2241
|
+
let sid = Snapshots.add(elements)
|
|
2242
|
+
var app: [String: Any] = ["pid": Int(pid), "bundleId": running.bundleIdentifier as Any, "name": running.localizedName as Any]
|
|
2243
|
+
var result: [String: Any] = ["snapshotId": sid, "nodes": nodes, "truncated": truncated]
|
|
2244
|
+
if enrich {
|
|
2245
|
+
app["lang"] = appLanguage(running) as Any
|
|
2246
|
+
result["window"] = rootWindowJSON(root, pid: pid)
|
|
2247
|
+
result["texts"] = texts
|
|
2248
|
+
}
|
|
2249
|
+
result["app"] = app
|
|
2250
|
+
return result
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// MARK: - ax_action verify (protocol 2)
|
|
2254
|
+
|
|
2255
|
+
/// What the caller's snapshot showed. Every text field is compared (absent or
|
|
2256
|
+
/// null means the element must have none) after verifyText: NFC, clipped to the
|
|
2257
|
+
/// snapshot's limit, whitespace-trimmed.
|
|
2258
|
+
struct VerifySpec {
|
|
2259
|
+
let pid: Int
|
|
2260
|
+
let role: String
|
|
2261
|
+
let subrole: String
|
|
2262
|
+
let title: String
|
|
2263
|
+
let description: String
|
|
2264
|
+
let help: String
|
|
2265
|
+
let identifier: String
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/// The target as it is right now, read immediately before the press.
|
|
2269
|
+
struct VerifyFacts {
|
|
2270
|
+
var pid: Int? = nil
|
|
2271
|
+
var frontmostPid: Int? = nil
|
|
2272
|
+
var role: String? = nil
|
|
2273
|
+
var subrole: String? = nil
|
|
2274
|
+
var title: String? = nil
|
|
2275
|
+
var description: String? = nil
|
|
2276
|
+
var help: String? = nil
|
|
2277
|
+
var identifier: String? = nil
|
|
2278
|
+
var enabled: Bool? = nil
|
|
2279
|
+
var secure = false
|
|
2280
|
+
var canPress = false
|
|
2281
|
+
/// Roles between the element and its window, nearest first (the window excluded).
|
|
2282
|
+
var ancestorRoles: [String] = []
|
|
2283
|
+
var windowSubrole: String? = nil
|
|
2284
|
+
var windowModal = false
|
|
2285
|
+
var focusedWindowIsSheet = false
|
|
2286
|
+
var inFocusedWindow = false
|
|
2287
|
+
var windowHasSheet = false
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/// What verifyText trims: .whitespacesAndNewlines plus U+FEFF. The same set as
|
|
2291
|
+
/// protocol.js (JavaScript's \s plus U+0085), and a superset of the
|
|
2292
|
+
/// String.prototype.trim the Neural Interface applies to the snapshot values it
|
|
2293
|
+
/// sends, so a label with a BOM at one end still verifies.
|
|
2294
|
+
let VERIFY_TRIM = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\u{FEFF}"))
|
|
2295
|
+
|
|
2296
|
+
/// NFC, the first `limit` Characters (the unit the snapshot clips in), trimmed.
|
|
2297
|
+
func verifyText(_ s: String?, _ limit: Int) -> String {
|
|
2298
|
+
guard let s = s else { return "" }
|
|
2299
|
+
return String(nfc(s).prefix(limit)).trimmingCharacters(in: VERIFY_TRIM)
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/// `verify` is only accepted with press; malformed → BAD_ARGS (protocol.js normalizeVerify).
|
|
2303
|
+
func parseVerify(_ a: Args, action: String) throws -> VerifySpec? {
|
|
2304
|
+
guard a.has("verify") else { return nil }
|
|
2305
|
+
guard let d = a.dict("verify") else { throw HelperError("BAD_ARGS", "verify must be an object") }
|
|
2306
|
+
guard action == "press" else { throw HelperError("BAD_ARGS", "verify is only allowed with press", ["action": action]) }
|
|
2307
|
+
guard let pid = Args.num(d["pid"]).map(toInt), pid > 0 else { throw HelperError("BAD_ARGS", "verify.pid is required") }
|
|
2308
|
+
guard let role = d["role"] as? String, !verifyText(role, 200).isEmpty else { throw HelperError("BAD_ARGS", "verify.role is required") }
|
|
2309
|
+
func text(_ key: String, _ limit: Int) throws -> String {
|
|
2310
|
+
guard let v = d[key], !(v is NSNull) else { return "" }
|
|
2311
|
+
guard let s = v as? String else { throw HelperError("BAD_ARGS", "verify.\(key) must be a string or null", ["field": key]) }
|
|
2312
|
+
return verifyText(s, limit)
|
|
2313
|
+
}
|
|
2314
|
+
return VerifySpec(
|
|
2315
|
+
pid: pid, role: verifyText(role, 200), subrole: try text("subrole", 200), title: try text("title", 200),
|
|
2316
|
+
description: try text("description", 200), help: try text("help", 200), identifier: try text("identifier", 120))
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/// Pure (covered by --self-test): the first check that no longer holds, or nil.
|
|
2320
|
+
/// Same order and field names as protocol.js compareVerify.
|
|
2321
|
+
func verifyCompare(_ want: VerifySpec, _ f: VerifyFacts) -> String? {
|
|
2322
|
+
if f.pid != want.pid { return "pid" }
|
|
2323
|
+
if f.frontmostPid != want.pid { return "frontmost" }
|
|
2324
|
+
if verifyText(f.role, 200) != want.role { return "role" }
|
|
2325
|
+
if verifyText(f.subrole, 200) != want.subrole { return "subrole" }
|
|
2326
|
+
if verifyText(f.title, 200) != want.title { return "title" }
|
|
2327
|
+
if verifyText(f.description, 200) != want.description { return "description" }
|
|
2328
|
+
if verifyText(f.help, 200) != want.help { return "help" }
|
|
2329
|
+
if verifyText(f.identifier, 120) != want.identifier { return "identifier" }
|
|
2330
|
+
if f.enabled != true { return "enabled" }
|
|
2331
|
+
if f.secure { return "secure" }
|
|
2332
|
+
if !f.canPress { return "press" }
|
|
2333
|
+
if f.ancestorRoles.contains("AXSheet") { return "sheet" }
|
|
2334
|
+
if f.ancestorRoles.contains("AXPopover") { return "popover" }
|
|
2335
|
+
if let s = f.windowSubrole, DIALOG_SUBROLES.contains(s) { return "dialog" }
|
|
2336
|
+
if f.windowModal { return "modal" }
|
|
2337
|
+
if f.focusedWindowIsSheet { return "sheet" }
|
|
2338
|
+
if !f.inFocusedWindow { return "window" }
|
|
2339
|
+
if f.windowHasSheet { return "sheet" }
|
|
2340
|
+
return nil
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/// Reads the facts verifyCompare needs: the element, its ancestors up to the
|
|
2344
|
+
/// window, the window, and the app's focused window.
|
|
2345
|
+
func verifyFacts(_ el: AXUIElement) -> VerifyFacts {
|
|
2346
|
+
var f = VerifyFacts()
|
|
2347
|
+
f.frontmostPid = NSWorkspace.shared.frontmostApplication.map { Int($0.processIdentifier) }
|
|
2348
|
+
guard let pid = AX.pid(el) else { return f }
|
|
2349
|
+
f.pid = Int(pid)
|
|
2350
|
+
let (err, v) = AX.multi(el, ["AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXHelp", "AXIdentifier", "AXEnabled", "AXWindow"])
|
|
2351
|
+
guard err == .success else { return f }
|
|
2352
|
+
f.role = v[0] as? String
|
|
2353
|
+
f.subrole = v[1] as? String
|
|
2354
|
+
f.title = v[2] as? String
|
|
2355
|
+
f.description = v[3] as? String
|
|
2356
|
+
f.help = v[4] as? String
|
|
2357
|
+
f.identifier = v[5] as? String
|
|
2358
|
+
f.enabled = (v[6] as? NSNumber)?.boolValue
|
|
2359
|
+
f.secure = f.subrole == "AXSecureTextField" || f.role == "AXSecureTextField"
|
|
2360
|
+
f.canPress = AX.actions(el).contains("AXPress")
|
|
2361
|
+
var cur = AX.element(AX.copy(el, "AXParent").1)
|
|
2362
|
+
var hops = 0
|
|
2363
|
+
while let c = cur, hops < 64 {
|
|
2364
|
+
guard let r = AX.string(c, "AXRole"), r != "AXWindow", r != "AXApplication" else { break }
|
|
2365
|
+
f.ancestorRoles.append(r)
|
|
2366
|
+
cur = AX.element(AX.copy(c, "AXParent").1)
|
|
2367
|
+
hops += 1
|
|
2368
|
+
}
|
|
2369
|
+
let win = AX.element(v[7])
|
|
2370
|
+
if let w = win {
|
|
2371
|
+
let (we, wv) = AX.multi(w, ["AXSubrole", "AXModal"])
|
|
2372
|
+
if we == .success {
|
|
2373
|
+
f.windowSubrole = wv[0] as? String
|
|
2374
|
+
f.windowModal = (wv[1] as? NSNumber)?.boolValue ?? false
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
let appEl = AXUIElementCreateApplication(pid)
|
|
2378
|
+
if let fw = AX.element(AX.copy(appEl, "AXFocusedWindow").1) {
|
|
2379
|
+
f.focusedWindowIsSheet = AX.string(fw, "AXRole") == "AXSheet"
|
|
2380
|
+
if let w = win { f.inFocusedWindow = CFEqual(fw, w) }
|
|
2381
|
+
f.windowHasSheet = AX.children(fw, limit: 500).contains { AX.string($0, "AXRole") == "AXSheet" }
|
|
2382
|
+
}
|
|
2383
|
+
return f
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
func verifyTarget(_ el: AXUIElement, _ want: VerifySpec, probe: Probe) throws {
|
|
2387
|
+
if let field = verifyCompare(want, verifyFacts(el)) {
|
|
2388
|
+
throw HelperError("TARGET_CHANGED", "the target is no longer what the snapshot showed (\(field)); nothing was pressed",
|
|
2389
|
+
["field": field, "probe": probe.json])
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/// Order: preInput → lookup (REF_EXPIRED) → probeElement → matchGuard on a fresh
|
|
2394
|
+
/// probe (window title re-read) → password-field check → verifyTarget → the
|
|
2395
|
+
/// action. Nothing runs between verifyTarget and AXPerformAction.
|
|
2396
|
+
func cmdAxAction(_ a: Args) throws -> Any {
|
|
2397
|
+
try preInput()
|
|
2398
|
+
AX.prepare()
|
|
2399
|
+
guard let sid = a.string("snapshotId"), let ref = a.string("ref") else { throw HelperError("BAD_ARGS", "snapshotId and ref are required") }
|
|
2400
|
+
let action = a.string("action") ?? "press"
|
|
2401
|
+
let verify = try parseVerify(a, action: action)
|
|
2402
|
+
guard let el = Snapshots.element(sid, ref) else {
|
|
2403
|
+
throw HelperError("REF_EXPIRED", "\(sid)/\(ref) is not in the last 4 snapshots", ["snapshotId": sid, "ref": ref])
|
|
2404
|
+
}
|
|
2405
|
+
let probe = probeElement(el)
|
|
2406
|
+
if probe.axError == .invalidUIElement { throw HelperError("REF_EXPIRED", "\(sid)/\(ref) no longer exists", ["snapshotId": sid, "ref": ref]) }
|
|
2407
|
+
if probe.axError == .cannotComplete { throw axFail(probe.axError, "ax_action") }
|
|
2408
|
+
if let e = matchGuard(probe, Config.guardCfg, context: "ax_action") { throw e }
|
|
2409
|
+
if action == "set_value" && Config.guardCfg.secureField {
|
|
2410
|
+
if probe.secure { throw HelperError("SECURE_FIELD", "refusing to set the value of a password field", ["probe": probe.json]) }
|
|
2411
|
+
if !probe.readable && IsSecureEventInputEnabled() {
|
|
2412
|
+
throw HelperError("SECURE_FIELD", "secure input is active and the target can't be read", ["probe": probe.json])
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
if let want = verify { try verifyTarget(el, want, probe: probe) }
|
|
2416
|
+
var err: AXError
|
|
2417
|
+
switch action {
|
|
2418
|
+
case "press", "toggle":
|
|
2419
|
+
err = AX.perform(el, "AXPress")
|
|
2420
|
+
case "focus":
|
|
2421
|
+
err = AX.setBool(el, "AXFocused", true)
|
|
2422
|
+
case "set_value":
|
|
2423
|
+
guard a.has("value") else { throw HelperError("BAD_ARGS", "value is required for set_value") }
|
|
2424
|
+
let v = a.raw["value"]!
|
|
2425
|
+
if let s = v as? String {
|
|
2426
|
+
err = AXUIElementSetAttributeValue(el, "AXValue" as CFString, s as CFString)
|
|
2427
|
+
} else if let n = v as? NSNumber {
|
|
2428
|
+
err = AXUIElementSetAttributeValue(el, "AXValue" as CFString, n)
|
|
2429
|
+
} else {
|
|
2430
|
+
throw HelperError("BAD_ARGS", "value must be a string, number or boolean")
|
|
2431
|
+
}
|
|
2432
|
+
case "expand", "collapse":
|
|
2433
|
+
let on = action == "expand"
|
|
2434
|
+
err = AX.setBool(el, "AXExpanded", on)
|
|
2435
|
+
if err != .success { err = AX.setBool(el, "AXDisclosing", on) }
|
|
2436
|
+
case "select":
|
|
2437
|
+
err = AX.setBool(el, "AXSelected", true)
|
|
2438
|
+
if err != .success { err = AX.perform(el, "AXPick") }
|
|
2439
|
+
case "scroll_into_view":
|
|
2440
|
+
err = AX.perform(el, "AXScrollToVisible")
|
|
2441
|
+
case "raise":
|
|
2442
|
+
if probe.role == "AXWindow" {
|
|
2443
|
+
err = AX.perform(el, "AXRaise")
|
|
2444
|
+
} else if let win = AX.element(AX.copy(el, "AXWindow").1) {
|
|
2445
|
+
err = AX.perform(win, "AXRaise")
|
|
2446
|
+
} else {
|
|
2447
|
+
err = .actionUnsupported
|
|
2448
|
+
}
|
|
2449
|
+
case "show_menu":
|
|
2450
|
+
err = AX.perform(el, "AXShowMenu")
|
|
2451
|
+
default:
|
|
2452
|
+
throw HelperError("BAD_ARGS", "unknown action '\(action)'")
|
|
2453
|
+
}
|
|
2454
|
+
if err != .success { throw axFail(err, "ax_action \(action)") }
|
|
2455
|
+
return ["target": probe.json]
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// MARK: - Configure, permissions, power
|
|
2459
|
+
|
|
2460
|
+
func cmdConfigure(_ a: Args) throws -> Any {
|
|
2461
|
+
var g = Config.guardCfg
|
|
2462
|
+
var m = Config.monitor
|
|
2463
|
+
var i = Config.input
|
|
2464
|
+
if let d = a.dict("guard") {
|
|
2465
|
+
try parseGuard(d, into: &g)
|
|
2466
|
+
} else if a.has("guard") {
|
|
2467
|
+
throw HelperError("BAD_ARGS", "guard must be an object")
|
|
2468
|
+
}
|
|
2469
|
+
if let d = a.dict("monitor") {
|
|
2470
|
+
if let b = Args.bool(d["armed"]) { m.armed = b }
|
|
2471
|
+
if let b = Args.bool(d["esc"]) { m.esc = b }
|
|
2472
|
+
if let b = Args.bool(d["failsafeCorner"]) { m.failsafeCorner = b }
|
|
2473
|
+
if let n = Args.num(d["cornerSizePt"]) { m.cornerSizePt = clampDouble(n, 1, 200) }
|
|
2474
|
+
}
|
|
2475
|
+
if let d = a.dict("input") {
|
|
2476
|
+
if let n = Args.num(d["typeChunk"]) { i.typeChunk = clampInt(toInt(n), 1, 20) }
|
|
2477
|
+
if let n = Args.num(d["typeDelayMs"]) { i.typeDelayMs = clampDouble(n, 0, 1000) }
|
|
2478
|
+
if let n = Args.num(d["moveSteps"]) { i.moveSteps = clampInt(toInt(n), 1, 200) }
|
|
2479
|
+
}
|
|
2480
|
+
Config.commit(g, m, i)
|
|
2481
|
+
Input.shared.setLatched(false)
|
|
2482
|
+
DispatchQueue.main.async { Monitor.ensureInstalled() }
|
|
2483
|
+
return [String: Any]()
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
func cmdRequestPermission(_ a: Args) throws -> Any {
|
|
2487
|
+
switch a.string("kind") {
|
|
2488
|
+
case "screen":
|
|
2489
|
+
_ = onMain { CGRequestScreenCaptureAccess() }
|
|
2490
|
+
case "accessibility":
|
|
2491
|
+
_ = onMain { AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) }
|
|
2492
|
+
default:
|
|
2493
|
+
throw HelperError("BAD_ARGS", "kind must be 'screen' or 'accessibility'")
|
|
2494
|
+
}
|
|
2495
|
+
return permissionsJSON()
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
enum Power {
|
|
2499
|
+
private static let lock = NSLock()
|
|
2500
|
+
private static var assertion: IOPMAssertionID = 0
|
|
2501
|
+
|
|
2502
|
+
static func set(_ hold: Bool) throws {
|
|
2503
|
+
lock.lock()
|
|
2504
|
+
defer { lock.unlock() }
|
|
2505
|
+
if hold && assertion == 0 {
|
|
2506
|
+
var id: IOPMAssertionID = 0
|
|
2507
|
+
let r = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
|
|
2508
|
+
IOPMAssertionLevel(kIOPMAssertionLevelOn), "SynaBun computer use" as CFString, &id)
|
|
2509
|
+
guard r == kIOReturnSuccess else { throw HelperError("INTERNAL", "could not hold the display awake (IOReturn \(r))") }
|
|
2510
|
+
assertion = id
|
|
2511
|
+
} else if !hold && assertion != 0 {
|
|
2512
|
+
IOPMAssertionRelease(assertion)
|
|
2513
|
+
assertion = 0
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
static func release() { try? set(false) }
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// MARK: - Global monitor (user input, emergency stop)
|
|
2521
|
+
|
|
2522
|
+
enum Monitor {
|
|
2523
|
+
private static var mouseMonitor: Any?
|
|
2524
|
+
private static var keyMonitor: Any?
|
|
2525
|
+
private static var wanted = false
|
|
2526
|
+
private static var inCorner = false
|
|
2527
|
+
private static var lastEmitNs: UInt64 = 0
|
|
2528
|
+
|
|
2529
|
+
/// Installed on the first `configure`, never at launch. The keyboard monitor needs
|
|
2530
|
+
/// Accessibility and is only added once it's granted (no prompt is ever triggered).
|
|
2531
|
+
static func ensureInstalled() {
|
|
2532
|
+
wanted = true
|
|
2533
|
+
if mouseMonitor == nil {
|
|
2534
|
+
let mask: NSEvent.EventTypeMask = [
|
|
2535
|
+
.mouseMoved, .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp,
|
|
2536
|
+
.leftMouseDragged, .rightMouseDragged, .otherMouseDragged, .scrollWheel,
|
|
2537
|
+
]
|
|
2538
|
+
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { handle($0) }
|
|
2539
|
+
}
|
|
2540
|
+
if keyMonitor == nil && AXIsProcessTrusted() {
|
|
2541
|
+
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { handle($0) }
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
static func permissionsChanged() { if wanted { ensureInstalled() } }
|
|
2546
|
+
|
|
2547
|
+
private static func handle(_ ev: NSEvent) {
|
|
2548
|
+
guard let cg = ev.cgEvent else { return }
|
|
2549
|
+
if cg.getIntegerValueField(.eventSourceUserData) == SYNB_STAMP { return } // ours
|
|
2550
|
+
let loc = cg.location
|
|
2551
|
+
let now = nowNs()
|
|
2552
|
+
let isKey = ev.type == .keyDown
|
|
2553
|
+
if isKey || ev.type == .leftMouseDown || ev.type == .rightMouseDown || ev.type == .otherMouseDown {
|
|
2554
|
+
Input.shared.noteUserInterrupt(now) // stops an in-progress `type`
|
|
2555
|
+
}
|
|
2556
|
+
let m = Config.monitor
|
|
2557
|
+
if m.armed {
|
|
2558
|
+
if m.esc && isKey && ev.keyCode == 53 && !ev.isARepeat { emergencyStop("esc") }
|
|
2559
|
+
if m.failsafeCorner && !isKey {
|
|
2560
|
+
let s = CGFloat(m.cornerSizePt)
|
|
2561
|
+
let inside = loc.x >= 0 && loc.y >= 0 && loc.x < s && loc.y < s
|
|
2562
|
+
if inside && !inCorner { emergencyStop("failsafe_corner") }
|
|
2563
|
+
inCorner = inside
|
|
2564
|
+
}
|
|
2565
|
+
} else {
|
|
2566
|
+
inCorner = false
|
|
2567
|
+
}
|
|
2568
|
+
if now &- lastEmitNs >= 250_000_000 { // ≤ 4 events per second
|
|
2569
|
+
lastEmitNs = now
|
|
2570
|
+
let kind = isKey ? "key" : (ev.type == .scrollWheel ? "scroll" : "mouse")
|
|
2571
|
+
emitEvent("user_input", ["kind": kind, "x": Double(loc.x), "y": Double(loc.y)])
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
private static func emergencyStop(_ reason: String) {
|
|
2576
|
+
Input.shared.setLatched(true)
|
|
2577
|
+
Input.shared.abort()
|
|
2578
|
+
emitEvent("emergency_stop", ["reason": reason])
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// MARK: - Commands
|
|
2583
|
+
|
|
2584
|
+
enum Commands {
|
|
2585
|
+
static func execute(_ cmd: String, _ a: Args) throws -> Any {
|
|
2586
|
+
switch cmd {
|
|
2587
|
+
case "permissions": return permissionsJSON()
|
|
2588
|
+
case "request_permission": return try cmdRequestPermission(a)
|
|
2589
|
+
case "displays": return Displays.list()
|
|
2590
|
+
case "session_state": return sessionStateJSON()
|
|
2591
|
+
case "configure": return try cmdConfigure(a)
|
|
2592
|
+
case "screenshot": return try cmdScreenshot(a)
|
|
2593
|
+
case "capture_rect": return try cmdCaptureRect(a)
|
|
2594
|
+
case "cursor": return pointJSON(cursorLocation())
|
|
2595
|
+
case "move": return try cmdMove(a)
|
|
2596
|
+
case "click": return try cmdClick(a)
|
|
2597
|
+
case "mouse_down": return try cmdMouseDown(a)
|
|
2598
|
+
case "mouse_up": return try cmdMouseUp(a)
|
|
2599
|
+
case "drag": return try cmdDrag(a)
|
|
2600
|
+
case "scroll": return try cmdScroll(a)
|
|
2601
|
+
case "type": return try cmdType(a)
|
|
2602
|
+
case "key": return try cmdKey(a, hold: false)
|
|
2603
|
+
case "hold_key": return try cmdKey(a, hold: true)
|
|
2604
|
+
case "probe_point":
|
|
2605
|
+
try requireTrusted()
|
|
2606
|
+
let p = try a.point()
|
|
2607
|
+
try requireOnScreen(p)
|
|
2608
|
+
return try probeAt(p).json
|
|
2609
|
+
case "probe_focus":
|
|
2610
|
+
try requireTrusted()
|
|
2611
|
+
return try probeFocus().json
|
|
2612
|
+
case "apps": return cmdApps()
|
|
2613
|
+
case "windows": return cmdWindows(a)
|
|
2614
|
+
case "open_app": return try cmdOpenApp(a)
|
|
2615
|
+
case "focus_app": return try cmdFocusApp(a)
|
|
2616
|
+
case "ax_snapshot": return try cmdAxSnapshot(a)
|
|
2617
|
+
case "ax_action": return try cmdAxAction(a)
|
|
2618
|
+
case "abort":
|
|
2619
|
+
Input.shared.abort()
|
|
2620
|
+
return [String: Any]()
|
|
2621
|
+
case "panic":
|
|
2622
|
+
Input.shared.panic()
|
|
2623
|
+
return [String: Any]()
|
|
2624
|
+
case "power":
|
|
2625
|
+
guard let hold = a.bool("holdDisplayAwake") else { throw HelperError("BAD_ARGS", "holdDisplayAwake is required") }
|
|
2626
|
+
try Power.set(hold)
|
|
2627
|
+
return [String: Any]()
|
|
2628
|
+
case "shutdown": return [String: Any]()
|
|
2629
|
+
default: throw HelperError("UNSUPPORTED", "unknown command '\(cmd)'")
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
enum Dispatcher {
|
|
2635
|
+
static let actions = DispatchQueue(label: "ai.synabun.desktop.actions", qos: .userInitiated)
|
|
2636
|
+
static let bypass: Set<String> = ["abort", "panic", "permissions", "session_state", "cursor", "shutdown"]
|
|
2637
|
+
|
|
2638
|
+
static func handle(_ line: String) {
|
|
2639
|
+
guard let data = line.data(using: .utf8),
|
|
2640
|
+
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
|
2641
|
+
hlog("warn", "ignored a stdin line that is not a JSON object")
|
|
2642
|
+
return
|
|
2643
|
+
}
|
|
2644
|
+
let id = obj["id"]
|
|
2645
|
+
guard let cmd = obj["cmd"] as? String else {
|
|
2646
|
+
replyError(id, HelperError("BAD_ARGS", "cmd is required"))
|
|
2647
|
+
return
|
|
2648
|
+
}
|
|
2649
|
+
let args = Args(obj["args"] as? [String: Any] ?? [:])
|
|
2650
|
+
if bypass.contains(cmd) { run(id, cmd, args) } else { actions.async { run(id, cmd, args) } }
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
static func run(_ id: Any?, _ cmd: String, _ args: Args) {
|
|
2654
|
+
autoreleasepool {
|
|
2655
|
+
do {
|
|
2656
|
+
reply(id, result: try Commands.execute(cmd, args))
|
|
2657
|
+
} catch let e as HelperError {
|
|
2658
|
+
replyError(id, e)
|
|
2659
|
+
} catch {
|
|
2660
|
+
replyError(id, HelperError("INTERNAL", String(describing: error)))
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
if cmd == "shutdown" { Lifecycle.exit(reason: "shutdown") }
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// MARK: - Lifecycle
|
|
2668
|
+
|
|
2669
|
+
enum Lifecycle {
|
|
2670
|
+
private static let lock = NSLock()
|
|
2671
|
+
private static var exiting = false
|
|
2672
|
+
private static var lastPerms: [String: Bool] = [:]
|
|
2673
|
+
private static var lastLocked: Bool? = nil
|
|
2674
|
+
private static var displaysWork: DispatchWorkItem?
|
|
2675
|
+
private static var timer: DispatchSourceTimer?
|
|
2676
|
+
private static var signalSources: [DispatchSourceSignal] = []
|
|
2677
|
+
|
|
2678
|
+
/// Releases what we hold (tracked state only) and the power assertion, then exits.
|
|
2679
|
+
static func exit(reason: String, code: Int32 = 0) {
|
|
2680
|
+
lock.lock()
|
|
2681
|
+
if exiting { lock.unlock(); return }
|
|
2682
|
+
exiting = true
|
|
2683
|
+
lock.unlock()
|
|
2684
|
+
Input.shared.abort()
|
|
2685
|
+
Power.release()
|
|
2686
|
+
Darwin.exit(code)
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
static func emitReady() {
|
|
2690
|
+
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
2691
|
+
let hash = ProcessInfo.processInfo.environment["SYNABUN_DESKTOP_SOURCE_HASH"]
|
|
2692
|
+
var captureInRect = false
|
|
2693
|
+
if #available(macOS 15.2, *) { captureInRect = true }
|
|
2694
|
+
emitEvent("ready", [
|
|
2695
|
+
"protocol": PROTOCOL_VERSION, "version": HELPER_VERSION,
|
|
2696
|
+
"sourceHash": ((hash?.isEmpty ?? true) ? nil : hash) as Any,
|
|
2697
|
+
"pid": Int(getpid()), "arch": HELPER_ARCH, "os": "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)",
|
|
2698
|
+
"features": [
|
|
2699
|
+
"sck": true, "captureInRect": captureInRect, "monitor": "nsevent",
|
|
2700
|
+
"axVerify": true, "guardPrefixes": true, "axEnrich": true,
|
|
2701
|
+
],
|
|
2702
|
+
])
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
static func setLocked(_ locked: Bool) { // main thread
|
|
2706
|
+
if lastLocked == locked { return }
|
|
2707
|
+
lastLocked = locked
|
|
2708
|
+
emitEvent("screen_lock", ["locked": locked])
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
static func installObservers() {
|
|
2712
|
+
for sig in [SIGTERM, SIGHUP] {
|
|
2713
|
+
signal(sig, SIG_IGN)
|
|
2714
|
+
let src = DispatchSource.makeSignalSource(signal: sig, queue: .global())
|
|
2715
|
+
src.setEventHandler { Lifecycle.exit(reason: "signal \(sig)") }
|
|
2716
|
+
src.resume()
|
|
2717
|
+
signalSources.append(src)
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
let dnc = DistributedNotificationCenter.default()
|
|
2721
|
+
dnc.addObserver(forName: Notification.Name("com.apple.screenIsLocked"), object: nil, queue: .main) { _ in setLocked(true) }
|
|
2722
|
+
dnc.addObserver(forName: Notification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main) { _ in setLocked(false) }
|
|
2723
|
+
|
|
2724
|
+
CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
|
|
2725
|
+
if flags.contains(.beginConfigurationFlag) { return }
|
|
2726
|
+
DispatchQueue.main.async { Lifecycle.displaysChanged() }
|
|
2727
|
+
}, nil)
|
|
2728
|
+
|
|
2729
|
+
lastPerms = permissionFlags()
|
|
2730
|
+
lastLocked = sessionInfo().locked
|
|
2731
|
+
let t = DispatchSource.makeTimerSource(queue: .main)
|
|
2732
|
+
t.schedule(deadline: .now() + 2, repeating: 2)
|
|
2733
|
+
t.setEventHandler {
|
|
2734
|
+
if getppid() == 1 { Lifecycle.exit(reason: "parent exited") }
|
|
2735
|
+
let perms = permissionFlags()
|
|
2736
|
+
if perms != lastPerms {
|
|
2737
|
+
lastPerms = perms
|
|
2738
|
+
emitEvent("permissions_changed", ["permissions": permissionsJSON()])
|
|
2739
|
+
Monitor.permissionsChanged()
|
|
2740
|
+
}
|
|
2741
|
+
setLocked(sessionInfo().locked)
|
|
2742
|
+
}
|
|
2743
|
+
t.resume()
|
|
2744
|
+
timer = t
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
static func displaysChanged() { // main thread, debounced
|
|
2748
|
+
displaysWork?.cancel()
|
|
2749
|
+
let work = DispatchWorkItem { emitEvent("displays_changed", ["displays": Displays.list()]) }
|
|
2750
|
+
displaysWork = work
|
|
2751
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
static func startReader() {
|
|
2755
|
+
let t = Thread {
|
|
2756
|
+
while let line = readLine(strippingNewline: true) {
|
|
2757
|
+
if line.isEmpty { continue }
|
|
2758
|
+
autoreleasepool { Dispatcher.handle(line) }
|
|
2759
|
+
}
|
|
2760
|
+
Lifecycle.exit(reason: "stdin closed")
|
|
2761
|
+
}
|
|
2762
|
+
t.name = "synabun-desktop.stdin"
|
|
2763
|
+
t.stackSize = 1 << 20
|
|
2764
|
+
t.start()
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// MARK: - Self-test (no screen access)
|
|
2769
|
+
|
|
2770
|
+
enum SelfTest {
|
|
2771
|
+
static func run() -> (Bool, [String: Any]) {
|
|
2772
|
+
var results: [[String: Any]] = []
|
|
2773
|
+
var allOk = true
|
|
2774
|
+
func check(_ name: String, _ ok: Bool, _ detail: String = "") {
|
|
2775
|
+
if !ok { allOk = false }
|
|
2776
|
+
results.append(["name": name, "ok": ok, "detail": detail])
|
|
2777
|
+
}
|
|
2778
|
+
func combo(_ s: String) -> KeyCombo? { try? parseCombo(s, layout: nil) }
|
|
2779
|
+
func comboCheck(_ s: String, _ mods: [Mod], _ code: CGKeyCode?, printable: Bool? = nil) {
|
|
2780
|
+
guard let c = combo(s) else { return check("combo \(s)", false, "did not parse") }
|
|
2781
|
+
var ok = Set(c.mods) == Set(mods) && c.mods.count == mods.count && c.keyCode == code
|
|
2782
|
+
if let p = printable { ok = ok && c.needsSecureCheck == p }
|
|
2783
|
+
check("combo \(s)", ok, "mods=\(c.mods.map { $0.rawValue }) key=\(c.keyCode.map { String($0) } ?? "nil") secure=\(c.needsSecureCheck)")
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// key combos / keymap (ANSI fallback — independent of the machine's layout)
|
|
2787
|
+
comboCheck("cmd+shift+t", [.cmd, .shift], 0x11, printable: false)
|
|
2788
|
+
comboCheck("cmd+T", [.cmd, .shift], 0x11)
|
|
2789
|
+
comboCheck("Return", [], 0x24, printable: false)
|
|
2790
|
+
comboCheck("ctrl+alt+Delete", [.ctrl, .alt], 0x75)
|
|
2791
|
+
comboCheck("super+space", [.cmd], 0x31, printable: false)
|
|
2792
|
+
comboCheck("space", [], 0x31, printable: true)
|
|
2793
|
+
comboCheck("shift+a", [.shift], 0x00, printable: true)
|
|
2794
|
+
comboCheck("cmd+v", [.cmd], 0x09, printable: true)
|
|
2795
|
+
comboCheck("cmd+c", [.cmd], 0x08, printable: false)
|
|
2796
|
+
comboCheck("cmd++", [.cmd, .shift], 0x18)
|
|
2797
|
+
comboCheck("Page_Down", [], 0x79)
|
|
2798
|
+
comboCheck("BackSpace", [], 0x33)
|
|
2799
|
+
comboCheck("Esc", [], 0x35)
|
|
2800
|
+
comboCheck("F12", [], 0x6F)
|
|
2801
|
+
comboCheck("F20", [], 0x5A)
|
|
2802
|
+
comboCheck("command+option+Escape", [.cmd, .alt], 0x35)
|
|
2803
|
+
comboCheck("shift", [.shift], nil, printable: false)
|
|
2804
|
+
comboCheck("ctrl+bracketleft", [.ctrl], 0x21)
|
|
2805
|
+
comboCheck("question", [.shift], 0x2C, printable: true)
|
|
2806
|
+
for bad in ["", "cmd+", "cmd+nope", "a+b", "hyper+x", "++x"] {
|
|
2807
|
+
check("combo rejects '\(bad)'", combo(bad) == nil)
|
|
2808
|
+
}
|
|
2809
|
+
let layout: LayoutMap = ["a": (0x0C, 0), "A": (0x0C, 2), "q": (0x00, 0)] // AZERTY-ish
|
|
2810
|
+
let fr = try? parseCombo("cmd+a", layout: layout)
|
|
2811
|
+
check("combo follows the layout map", fr?.keyCode == 0x0C, "key=\(fr?.keyCode.map { String($0) } ?? "nil")")
|
|
2812
|
+
|
|
2813
|
+
// fit math
|
|
2814
|
+
let f1 = fitSize(width: 1470, height: 956, fitW: 1280, fitH: 800, scale: 2)
|
|
2815
|
+
check("fit 1470x956@2 into 1280x800", f1.w == 1230 && f1.h == 800, "\(f1.w)x\(f1.h)")
|
|
2816
|
+
let f2 = fitSize(width: 1470, height: 956, fitW: 4000, fitH: 4000, scale: 2)
|
|
2817
|
+
check("fit never exceeds native pixels", f2.w == 2940 && f2.h == 1912, "\(f2.w)x\(f2.h)")
|
|
2818
|
+
let f3 = fitSize(width: 1920, height: 1080, fitW: 1024, fitH: 768, scale: 1)
|
|
2819
|
+
check("fit 1920x1080@1 into 1024x768", f3.w == 1024 && f3.h == 576, "\(f3.w)x\(f3.h)")
|
|
2820
|
+
let f4 = fitSize(width: 300, height: 200, fitW: 1568, fitH: 1568, scale: 2)
|
|
2821
|
+
check("fit region upscales only to backing scale", f4.w == 600 && f4.h == 400, "\(f4.w)x\(f4.h)")
|
|
2822
|
+
|
|
2823
|
+
// scroll sign conversion
|
|
2824
|
+
let s1 = scrollWheelDeltas(dx: 0, dy: 3)
|
|
2825
|
+
check("scroll dy>0 (down) → wheel1 < 0", s1.wheel1 == -3 && s1.wheel2 == 0)
|
|
2826
|
+
let s2 = scrollWheelDeltas(dx: -2, dy: -1)
|
|
2827
|
+
check("scroll dx<0 (left) → wheel2 > 0, dy<0 (up) → wheel1 > 0", s2.wheel1 == 1 && s2.wheel2 == 2)
|
|
2828
|
+
let s3 = scrollWheelDeltas(dx: Int.min, dy: Int.max)
|
|
2829
|
+
check("scroll deltas never trap", s3.wheel1 == -1_000_000 && s3.wheel2 == 1_000_000)
|
|
2830
|
+
check("number → int never traps", toInt(1e300) == 1_000_000_000_000_000 && toInt(-.infinity) == 0 && toInt(.nan) == 0 && toInt(2.5) == 3)
|
|
2831
|
+
|
|
2832
|
+
// text segmentation
|
|
2833
|
+
let segs = segmentText("ab\ncd\te\r\nf", chunk: 20)
|
|
2834
|
+
let shape = segs.map { seg -> String in
|
|
2835
|
+
switch seg { case .text(let s): return "t:\(s)"; case .key(let c, _): return "k:\(c)" }
|
|
2836
|
+
}
|
|
2837
|
+
check("segment newlines/tabs", shape == ["t:ab", "k:36", "t:cd", "k:48", "t:e", "k:36", "t:f"], shape.joined(separator: ","))
|
|
2838
|
+
check("segment utf16 accounting", segs.reduce(0) { $0 + $1.utf16Count } == "ab\ncd\te\r\nf".utf16.count)
|
|
2839
|
+
let emoji = String(repeating: "👩👩👧", count: 4) // 8 UTF-16 units each
|
|
2840
|
+
let esegs = segmentText(emoji, chunk: 20)
|
|
2841
|
+
let noSplit = esegs.allSatisfy { seg in
|
|
2842
|
+
if case .text(let s) = seg { return s.utf16.count <= 20 && s.count >= 1 } else { return false }
|
|
2843
|
+
}
|
|
2844
|
+
check("segment keeps grapheme clusters whole", noSplit && esegs.count == 2, "\(esegs.count) chunks")
|
|
2845
|
+
check("segment chunk size", segmentText(String(repeating: "x", count: 45), chunk: 20).count == 3)
|
|
2846
|
+
|
|
2847
|
+
// guard matching
|
|
2848
|
+
var g = GuardConfig()
|
|
2849
|
+
g.blocked = [
|
|
2850
|
+
BlockRule(id: "pw", bundleIds: ["com.1password.1password"], nameRe: nil, windowTitleRe: nil, reason: "password manager"),
|
|
2851
|
+
BlockRule(id: "bank", bundleIds: [], nameRe: nil, windowTitleRe: try? NSRegularExpression(pattern: "chase|wells fargo", options: [.caseInsensitive]), reason: "banking"),
|
|
2852
|
+
BlockRule(id: "term", bundleIds: [], nameRe: try? NSRegularExpression(pattern: "^terminal$", options: [.caseInsensitive]), windowTitleRe: nil, reason: "shell"),
|
|
2853
|
+
]
|
|
2854
|
+
g.protectedWindows = [ProtectRule(index: 0, bundleIds: ["com.apple.Safari"], titleRe: try! NSRegularExpression(pattern: "keychain", options: [.caseInsensitive]), reason: "secrets")]
|
|
2855
|
+
var p = Probe()
|
|
2856
|
+
p.bundleId = "COM.1password.1PASSWORD"
|
|
2857
|
+
check("guard blocks by bundle id (case-insensitive)", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
|
|
2858
|
+
p = Probe(); p.bundleId = "com.google.Chrome"; p.app = "Google Chrome"; p.windowTitle = "Chase Online"
|
|
2859
|
+
check("guard blocks by window title", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
|
|
2860
|
+
p = Probe(); p.app = "Terminal"
|
|
2861
|
+
check("guard blocks by app name", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
|
|
2862
|
+
p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"; p.windowTitle = "Keychain Access Help"
|
|
2863
|
+
check("guard protects matching windows", matchGuard(p, g, context: "t")?.code == "PROTECTED_WINDOW")
|
|
2864
|
+
p = Probe(); p.bundleId = "com.apple.TextEdit"; p.app = "TextEdit"; p.windowTitle = "Keychain notes"
|
|
2865
|
+
check("guard protected rule is scoped to its bundle ids", matchGuard(p, g, context: "t") == nil)
|
|
2866
|
+
p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"
|
|
2867
|
+
check("guard ignores unknown titles", matchGuard(p, g, context: "t") == nil)
|
|
2868
|
+
check("guard rejects invalid regex", (try? compileRegex("(", field: "x")) == nil)
|
|
2869
|
+
check("guard treats empty regex as absent", ((try? compileRegex("", field: "x")) ?? nil) == nil)
|
|
2870
|
+
|
|
2871
|
+
// protocol 2 guards: bundle-id prefixes, app names, NFC
|
|
2872
|
+
func re(_ pattern: String) -> NSRegularExpression? { (try? compileRegex(pattern, field: "t")) ?? nil }
|
|
2873
|
+
func matchedBy(_ e: HelperError?) -> String? { (e?.details?["rule"] as? [String: Any])?["matched"] as? String }
|
|
2874
|
+
let webAppId = "com.apple.Safari.WebApp.7EF0F3F3-27FA-4C9A-9D7C-74020761B996"
|
|
2875
|
+
var gp = GuardConfig()
|
|
2876
|
+
gp.blocked = [BlockRule(id: "webapps", bundleIds: [], bundlePrefixes: ["com.apple.Safari.WebApp."], nameRe: nil, windowTitleRe: nil, reason: "web apps")]
|
|
2877
|
+
p = Probe(); p.bundleId = webAppId; p.app = "SynaBun"
|
|
2878
|
+
check("guard blocks by bundle prefix", matchedBy(matchGuard(p, gp, context: "t")) == "bundlePrefix")
|
|
2879
|
+
p = Probe(); p.bundleId = "COM.APPLE.SAFARI.WEBAPP.X1"
|
|
2880
|
+
check("bundle prefixes compare case-insensitively", matchGuard(p, gp, context: "t")?.code == "BLOCKED_APP")
|
|
2881
|
+
p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"
|
|
2882
|
+
check("a prefix never matches the bare browser id", matchGuard(p, gp, context: "t") == nil)
|
|
2883
|
+
p = Probe(); p.bundleId = "com.apple.Safari.WebApp"
|
|
2884
|
+
check("a prefix needs its trailing dot", matchGuard(p, gp, context: "t") == nil)
|
|
2885
|
+
|
|
2886
|
+
var gw = GuardConfig()
|
|
2887
|
+
gw.protectedWindows = [ProtectRule(
|
|
2888
|
+
index: 0, id: "synabun-ui", bundleIds: ["com.apple.Safari"], bundlePrefixes: ["com.apple.Safari.WebApp."],
|
|
2889
|
+
titleRe: re("Neural Memory Interface|SynaBun|SynApp|Neural Interface"),
|
|
2890
|
+
appNameRe: re("^(SynaBun|SynApp|Neural (Memory )?Interface)\\b"), reason: "SynaBun")]
|
|
2891
|
+
p = Probe(); p.bundleId = webAppId; p.app = "SynaBun"; p.windowTitle = "Restarting..."
|
|
2892
|
+
let byName = matchGuard(p, gw, context: "t")
|
|
2893
|
+
check("web app protected by its app name, whatever the title", byName?.code == "PROTECTED_WINDOW" && matchedBy(byName) == "appName")
|
|
2894
|
+
p.windowTitle = nil
|
|
2895
|
+
check("web app protected by its app name with no title", matchGuard(p, gw, context: "t")?.code == "PROTECTED_WINDOW")
|
|
2896
|
+
p = Probe(); p.bundleId = "com.apple.Safari.WebApp.ABC"; p.app = "Docs"; p.windowTitle = "SynaBun"
|
|
2897
|
+
check("web app protected by its title", matchedBy(matchGuard(p, gw, context: "t")) == "title")
|
|
2898
|
+
p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"; p.windowTitle = "Restarting..."
|
|
2899
|
+
check("Safari itself is not protected by the app-name rule", matchGuard(p, gw, context: "t") == nil)
|
|
2900
|
+
p = Probe(); p.bundleId = "com.apple.TextEdit"; p.app = "SynaBun"; p.windowTitle = "SynaBun notes"
|
|
2901
|
+
check("appNameRe is scoped like titleRe", matchGuard(p, gw, context: "t") == nil)
|
|
2902
|
+
|
|
2903
|
+
var gs = GuardConfig()
|
|
2904
|
+
gs.protectedWindows = [ProtectRule(index: 0, bundleIds: ["com.apple.systempreferences", "com.apple.Settings"],
|
|
2905
|
+
titleRe: re("^(?:Câmera|Fotos)$|Privacidade e Segurança|Acesso Total ao Disco"), reason: "settings")]
|
|
2906
|
+
p = Probe(); p.bundleId = "com.apple.systempreferences"; p.app = "Ajustes do Sistema"; p.windowTitle = "Privacidade e Seguranc\u{0327}a"
|
|
2907
|
+
check("protected by a pt title in NFD", matchGuard(p, gs, context: "t")?.code == "PROTECTED_WINDOW")
|
|
2908
|
+
p.windowTitle = "Ca\u{0302}mera"
|
|
2909
|
+
check("protected by an anchored pt name in NFD", matchGuard(p, gs, context: "t")?.code == "PROTECTED_WINDOW")
|
|
2910
|
+
p.windowTitle = "Câmera lenta"
|
|
2911
|
+
check("anchored names match whole titles only", matchGuard(p, gs, context: "t") == nil)
|
|
2912
|
+
p.windowTitle = "Aparência"
|
|
2913
|
+
check("an unprotected pt pane passes", matchGuard(p, gs, context: "t") == nil)
|
|
2914
|
+
var gd = GuardConfig()
|
|
2915
|
+
gd.protectedWindows = [ProtectRule(index: 0, bundleIds: [], titleRe: re("Seguranc\u{0327}a"), reason: "")]
|
|
2916
|
+
p = Probe(); p.windowTitle = "Privacidade e Segurança"
|
|
2917
|
+
check("an NFD pattern matches an NFC title", matchGuard(p, gd, context: "t")?.code == "PROTECTED_WINDOW")
|
|
2918
|
+
|
|
2919
|
+
var pg = GuardConfig()
|
|
2920
|
+
let parsed = (try? parseGuard([
|
|
2921
|
+
"blockedApps": [["id": "w", "bundlePrefixes": ["com.apple.Safari.WebApp."]]],
|
|
2922
|
+
"protectedWindows": [["id": "ui", "appNameRe": "^SynaBun\\b", "bundlePrefixes": ["com.google.Chrome.app."]]],
|
|
2923
|
+
], into: &pg)) != nil
|
|
2924
|
+
check("parseGuard reads bundlePrefixes and appNameRe", parsed && pg.blocked.first?.bundlePrefixes == ["com.apple.Safari.WebApp."]
|
|
2925
|
+
&& pg.protectedWindows.first?.appNameRe != nil && pg.protectedWindows.first?.titleRe == nil && pg.protectedWindows.first?.id == "ui")
|
|
2926
|
+
for bad in ["com.apple.Safari", "com.", ".com.apple.", "com..apple.", "com apple.", ""] {
|
|
2927
|
+
var gb = GuardConfig()
|
|
2928
|
+
check("parseGuard rejects the prefix '\(bad)'", (try? parseGuard(["blockedApps": [["id": "x", "bundlePrefixes": [bad]]]], into: &gb)) == nil)
|
|
2929
|
+
}
|
|
2930
|
+
var gn = GuardConfig()
|
|
2931
|
+
check("parseGuard needs titleRe or appNameRe", (try? parseGuard(["protectedWindows": [["bundleIds": ["com.x.y"]]]], into: &gn)) == nil)
|
|
2932
|
+
|
|
2933
|
+
// group labels (never a window title)
|
|
2934
|
+
check("group of a labelled container", groupForChildren(role: "AXOutline", title: nil, desc: " Barra\nlateral ", inherited: "toolbar") == "outline Barra lateral")
|
|
2935
|
+
check("group of a bare toolbar", groupForChildren(role: "AXToolbar", title: nil, desc: nil, inherited: nil) == "toolbar")
|
|
2936
|
+
check("an unlabelled group inherits", groupForChildren(role: "AXGroup", title: " ", desc: nil, inherited: "sheet") == "sheet")
|
|
2937
|
+
check("a window is never a group", groupForChildren(role: "AXWindow", title: "Secret.txt", desc: nil, inherited: nil) == nil)
|
|
2938
|
+
check("groups are clipped to 60", groupForChildren(role: "AXGroup", title: String(repeating: "x", count: 100), desc: nil, inherited: nil)?.count == 60)
|
|
2939
|
+
|
|
2940
|
+
// ax_action verify: the decision, without AX
|
|
2941
|
+
let want = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: "Voltar", description: "", help: "Go back", identifier: "back")
|
|
2942
|
+
var facts = VerifyFacts()
|
|
2943
|
+
facts.pid = 42; facts.frontmostPid = 42; facts.role = "AXButton"; facts.title = " Voltar "; facts.help = "Go back"
|
|
2944
|
+
facts.identifier = "back"; facts.enabled = true; facts.canPress = true; facts.inFocusedWindow = true
|
|
2945
|
+
facts.windowSubrole = "AXStandardWindow"; facts.ancestorRoles = ["AXGroup", "AXToolbar"]
|
|
2946
|
+
check("verify passes an unchanged target", verifyCompare(want, facts) == nil, verifyCompare(want, facts) ?? "")
|
|
2947
|
+
func changed(_ label: String, _ field: String, _ mutate: (inout VerifyFacts) -> Void) {
|
|
2948
|
+
var f = facts
|
|
2949
|
+
mutate(&f)
|
|
2950
|
+
let got = verifyCompare(want, f)
|
|
2951
|
+
check("verify: \(label) → \(field)", got == field, got ?? "nil")
|
|
2952
|
+
}
|
|
2953
|
+
changed("other process", "pid") { $0.pid = 43 }
|
|
2954
|
+
changed("app in the background", "frontmost") { $0.frontmostPid = 7 }
|
|
2955
|
+
changed("role", "role") { $0.role = "AXLink" }
|
|
2956
|
+
changed("subrole", "subrole") { $0.subrole = "AXCloseButton" }
|
|
2957
|
+
changed("relabelled", "title") { $0.title = "Apagar" }
|
|
2958
|
+
changed("description added", "description") { $0.description = "trash" }
|
|
2959
|
+
changed("help removed", "help") { $0.help = nil }
|
|
2960
|
+
changed("identifier", "identifier") { $0.identifier = "delete" }
|
|
2961
|
+
changed("disabled", "enabled") { $0.enabled = false }
|
|
2962
|
+
changed("enabled unknown", "enabled") { $0.enabled = nil }
|
|
2963
|
+
changed("secure", "secure") { $0.secure = true }
|
|
2964
|
+
changed("no AXPress", "press") { $0.canPress = false }
|
|
2965
|
+
changed("inside a sheet", "sheet") { $0.ancestorRoles = ["AXGroup", "AXSheet"] }
|
|
2966
|
+
changed("inside a popover", "popover") { $0.ancestorRoles = ["AXPopover"] }
|
|
2967
|
+
changed("dialog window", "dialog") { $0.windowSubrole = "AXSystemDialog" }
|
|
2968
|
+
changed("modal window", "modal") { $0.windowModal = true }
|
|
2969
|
+
changed("focused window is a sheet", "sheet") { $0.focusedWindowIsSheet = true }
|
|
2970
|
+
changed("not the focused window", "window") { $0.inFocusedWindow = false }
|
|
2971
|
+
changed("a sheet opened over the window", "sheet") { $0.windowHasSheet = true }
|
|
2972
|
+
var nfdFacts = facts
|
|
2973
|
+
nfdFacts.title = "Seguranc\u{0327}a"
|
|
2974
|
+
let nfcWant = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: "Segurança", description: "", help: "Go back", identifier: "back")
|
|
2975
|
+
check("verify compares text in NFC", verifyCompare(nfcWant, nfdFacts) == nil)
|
|
2976
|
+
let seen = String(repeating: "a", count: 200)
|
|
2977
|
+
var longFacts = facts
|
|
2978
|
+
longFacts.title = seen + " and more than the snapshot kept"
|
|
2979
|
+
let longWant = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: seen, description: "", help: "Go back", identifier: "back")
|
|
2980
|
+
check("verify ignores text past the snapshot's limit", verifyCompare(longWant, longFacts) == nil)
|
|
2981
|
+
// The whole chain: the snapshot clips (trimmed), the Neural Interface trims
|
|
2982
|
+
// (String.prototype.trim) and sends it as verify, the helper re-reads the live label.
|
|
2983
|
+
func roundTrip(live: String, sent: String) -> String? {
|
|
2984
|
+
guard let want = (try? parseVerify(Args(["verify": ["pid": 42, "role": "AXButton", "title": sent, "help": "Go back", "identifier": "back"]]), action: "press")) ?? nil else { return "parse" }
|
|
2985
|
+
var f = facts
|
|
2986
|
+
f.title = live
|
|
2987
|
+
return verifyCompare(want, f)
|
|
2988
|
+
}
|
|
2989
|
+
let longLabel = String(repeating: "Mover para o Lixo ", count: 15)
|
|
2990
|
+
check("a label longer than 200 verifies against its snapshot copy",
|
|
2991
|
+
roundTrip(live: longLabel, sent: trimmed(longLabel, 200)!.trimmingCharacters(in: .whitespaces)) == nil)
|
|
2992
|
+
let nfdLong = String(repeating: "e\u{0301}", count: 250)
|
|
2993
|
+
check("a decomposed label longer than 200 verifies against its snapshot copy", roundTrip(live: nfdLong, sent: trimmed(nfdLong, 200)!) == nil)
|
|
2994
|
+
check("verify trims a BOM the way the Neural Interface does", roundTrip(live: "\u{FEFF}Voltar\u{FEFF}", sent: "Voltar") == nil)
|
|
2995
|
+
check("verify still sees a changed long label", roundTrip(live: "x" + longLabel, sent: trimmed(longLabel, 200)!) == "title")
|
|
2996
|
+
func verifyArgsError(_ raw: [String: Any], _ action: String) -> String? {
|
|
2997
|
+
do { _ = try parseVerify(Args(raw), action: action); return nil } catch let e as HelperError { return e.code } catch { return "?" }
|
|
2998
|
+
}
|
|
2999
|
+
let okVerify: [String: Any] = ["pid": 42, "role": "AXButton", "title": "Voltar", "subrole": NSNull()]
|
|
3000
|
+
check("verify is accepted with press", verifyArgsError(["verify": okVerify], "press") == nil)
|
|
3001
|
+
check("verify is refused with toggle", verifyArgsError(["verify": okVerify], "toggle") == "BAD_ARGS")
|
|
3002
|
+
check("verify must be an object", verifyArgsError(["verify": "yes"], "press") == "BAD_ARGS")
|
|
3003
|
+
check("verify needs a pid", verifyArgsError(["verify": ["role": "AXButton"]], "press") == "BAD_ARGS")
|
|
3004
|
+
check("verify needs a role", verifyArgsError(["verify": ["pid": 42]], "press") == "BAD_ARGS")
|
|
3005
|
+
check("verify text fields are strings or null", verifyArgsError(["verify": ["pid": 42, "role": "AXButton", "title": 5]], "press") == "BAD_ARGS")
|
|
3006
|
+
let spec = (try? parseVerify(Args(["verify": okVerify]), action: "press")) ?? nil
|
|
3007
|
+
check("verify normalizes what it was given", spec?.title == "Voltar" && spec?.subrole == "" && spec?.help == "" && spec?.pid == 42)
|
|
3008
|
+
|
|
3009
|
+
// JSON round-trip
|
|
3010
|
+
let original: [String: Any] = ["id": 17, "ok": true, "s": "line1\nline2 \"q\" \\ é 👩👩👧 \u{2028}", "n": 1.5, "a": [1, "x", NSNull()], "nan": Double.nan]
|
|
3011
|
+
if let data = encodeJSON(original), let back = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
|
3012
|
+
let str = String(data: data, encoding: .utf8) ?? ""
|
|
3013
|
+
let ok = (back["s"] as? String) == (original["s"] as? String) && (back["id"] as? NSNumber)?.intValue == 17
|
|
3014
|
+
&& (back["ok"] as? Bool) == true && back["nan"] is NSNull && !str.contains("\n")
|
|
3015
|
+
check("json round-trip", ok, str)
|
|
3016
|
+
} else {
|
|
3017
|
+
check("json round-trip", false, "encode failed")
|
|
3018
|
+
}
|
|
3019
|
+
check("json drops non-finite numbers", encodeJSON(["x": Double.infinity]) != nil)
|
|
3020
|
+
|
|
3021
|
+
return (allOk, ["ok": allOk, "version": HELPER_VERSION, "protocol": PROTOCOL_VERSION, "tests": results])
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
// MARK: - Main
|
|
3026
|
+
|
|
3027
|
+
func printLine(_ obj: [String: Any]) {
|
|
3028
|
+
if let data = encodeJSON(obj), let s = String(data: data, encoding: .utf8) { print(s) }
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
let arguments = CommandLine.arguments
|
|
3032
|
+
if arguments.contains("--version") {
|
|
3033
|
+
printLine(["version": HELPER_VERSION, "protocol": PROTOCOL_VERSION])
|
|
3034
|
+
exit(0)
|
|
3035
|
+
}
|
|
3036
|
+
if arguments.contains("--self-test") {
|
|
3037
|
+
let (ok, report) = SelfTest.run()
|
|
3038
|
+
printLine(report)
|
|
3039
|
+
exit(ok ? 0 : 1)
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
signal(SIGPIPE, SIG_IGN) // a dead parent surfaces as a write error, not a kill
|
|
3043
|
+
signal(SIGINT, SIG_IGN) // Ctrl+C in the launching terminal: the server owns the shutdown
|
|
3044
|
+
|
|
3045
|
+
let application = NSApplication.shared
|
|
3046
|
+
application.setActivationPolicy(.accessory)
|
|
3047
|
+
// No App Nap: timers and the event monitor must stay prompt while idle.
|
|
3048
|
+
let activity = ProcessInfo.processInfo.beginActivity(options: [.userInitiatedAllowingIdleSystemSleep, .latencyCritical],
|
|
3049
|
+
reason: "SynaBun desktop control")
|
|
3050
|
+
Lifecycle.installObservers()
|
|
3051
|
+
DispatchQueue.main.async {
|
|
3052
|
+
Lifecycle.emitReady()
|
|
3053
|
+
Lifecycle.startReader()
|
|
3054
|
+
}
|
|
3055
|
+
application.run()
|
|
3056
|
+
withExtendedLifetime(activity) {}
|