Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cc816969 | ||
|
|
b0d0914ca0 | ||
|
|
b33334620a | ||
|
|
ebc4fe57d7 | ||
|
|
5909cf36b9 | ||
|
|
e3a97b96d9 | ||
|
|
4942f3af0f | ||
|
|
ad44e32910 | ||
|
|
b0b77aae88 | ||
|
|
007f8c9222 | ||
|
|
dbd5a76cb0 | ||
|
|
630b256aee | ||
|
|
e170bc397d | ||
|
|
d2f033aee5 | ||
|
|
8f12b47b6d | ||
|
|
e8b382e8ed |
No files matched your search
@@ -1,26 +0,0 @@
|
|||||||
# detect-secrets exclusion patterns (regex)
|
|
||||||
#
|
|
||||||
# Note: detect-secrets does not read this file by default. If you want these
|
|
||||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
|
||||||
# / --exclude-lines) or into a baseline's filters_used.
|
|
||||||
|
|
||||||
[exclude-files]
|
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
|
||||||
|
|
||||||
[exclude-lines]
|
|
||||||
# Fastlane checks for private key marker; not a real key.
|
|
||||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
|
||||||
# UI label string for Anthropic auth mode.
|
|
||||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
|
||||||
# CodingKeys mapping uses apiKey literal.
|
|
||||||
pattern = case apikey = "apiKey"
|
|
||||||
# Schema labels referencing password fields (not actual secrets).
|
|
||||||
pattern = "gateway\.remote\.password"
|
|
||||||
pattern = "gateway\.auth\.password"
|
|
||||||
# Schema label for talk API key (label text only).
|
|
||||||
pattern = "talk\.apiKey"
|
|
||||||
# checking for typeof is not something we care about.
|
|
||||||
pattern = === "string"
|
|
||||||
# specific optional-chaining password check that didn't match the line above.
|
|
||||||
pattern = typeof remote\?\.password === "string"
|
|
||||||
@@ -74,9 +74,9 @@ jobs:
|
|||||||
- runtime: node
|
- runtime: node
|
||||||
task: protocol
|
task: protocol
|
||||||
command: pnpm protocol:check
|
command: pnpm protocol:check
|
||||||
- runtime: node
|
- runtime: bun
|
||||||
task: format
|
task: lint
|
||||||
command: pnpm format
|
command: bunx biome check src
|
||||||
- runtime: bun
|
- runtime: bun
|
||||||
task: test
|
task: test
|
||||||
command: bunx vitest run
|
command: bunx vitest run
|
||||||
@@ -141,31 +141,6 @@ jobs:
|
|||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
secrets:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install detect-secrets
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install detect-secrets==1.5.0
|
|
||||||
|
|
||||||
- name: Detect secrets
|
|
||||||
run: |
|
|
||||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
|
||||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
checks-windows:
|
checks-windows:
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -29,5 +29,4 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -55,5 +55,3 @@ apps/ios/*.mobileprovision
|
|||||||
# Local untracked files
|
# Local untracked files
|
||||||
.local/
|
.local/
|
||||||
.vscode/
|
.vscode/
|
||||||
IDENTITY.md
|
|
||||||
USER.md
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
|
||||||
"extends": ["recommended"]
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.5.0",
|
|
||||||
"plugins_used": [
|
|
||||||
{
|
|
||||||
"name": "ArtifactoryDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AWSKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AzureStorageKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Base64HighEntropyString",
|
|
||||||
"limit": 4.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BasicAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CloudantDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DiscordBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitHubTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitLabTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "HexHighEntropyString",
|
|
||||||
"limit": 3.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCloudIamDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCosHmacDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IPPublicDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "JwtTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "KeywordDetector",
|
|
||||||
"keyword_exclude": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "MailchimpDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "NpmDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OpenAIDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PrivateKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PypiTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SendGridDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SlackDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SoftlayerDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SquareOAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StripeDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TelegramBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TwilioKeyDetector"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filters_used": [
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
|
||||||
"filename": ".secrets.baseline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
|
||||||
"min_level": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
|
||||||
"pattern": [
|
|
||||||
"(^|/)pnpm-lock\\.yaml$"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_line",
|
|
||||||
"pattern": [
|
|
||||||
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
|
|
||||||
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
|
|
||||||
"case apikey = \"apiKey\"",
|
|
||||||
"\"gateway\\.remote\\.password\"",
|
|
||||||
"\"gateway\\.auth\\.password\"",
|
|
||||||
"\"talk\\.apiKey\"",
|
|
||||||
"=== \"string\"",
|
|
||||||
"typeof remote\\?\\.password === \"string\""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"results": {
|
|
||||||
".env.example": [
|
|
||||||
{
|
|
||||||
"type": "Twilio API Key",
|
|
||||||
"filename": ".env.example",
|
|
||||||
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"appcast.xml": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 19
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 26
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 42
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 83
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 27
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/configuration.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 268
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 465
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 718
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 760
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 859
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 982
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/faq.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 593
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 650
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills-config.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills-config.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 28
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 97
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/tailscale.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/tailscale.md",
|
|
||||||
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/talk.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/talk.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/telegram.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/telegram.md",
|
|
||||||
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 57
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/local-places/SERVER_README.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/local-places/SERVER_README.md",
|
|
||||||
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 28
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/openai-whisper-api/SKILL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/openai-whisper-api/SKILL.md",
|
|
||||||
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 39
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/trello/SKILL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/trello/SKILL.md",
|
|
||||||
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 18
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/models-config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 90
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/skills.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 158
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 265
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 462
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 490
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/browser/target-id.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Hex High Entropy String",
|
|
||||||
"filename": "src/browser/target-id.test.ts",
|
|
||||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 16
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/antigravity-oauth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 17
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 20
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/onboard-auth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/commands/onboard-auth.ts",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/config/config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/config/config.test.ts",
|
|
||||||
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 520
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/gateway/server.auth.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 109
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/shell-env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 56
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 73
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 75
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/web/qr-image.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Hex High Entropy String",
|
|
||||||
"filename": "src/web/qr-image.test.ts",
|
|
||||||
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 12
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"vendor/a2ui/README.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "vendor/a2ui/README.md",
|
|
||||||
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 123
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"generated_at": "2026-01-05T13:01:00Z"
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
## Docs Linking (Mintlify)
|
||||||
@@ -26,12 +25,12 @@
|
|||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||||
- Type-check/build: `pnpm build` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||||
- Add brief code comments for tricky or non-obvious logic.
|
- Add brief code comments for tricky or non-obvious logic.
|
||||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||||
@@ -60,9 +59,6 @@
|
|||||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||||
|
|
||||||
## Shorthand Commands
|
|
||||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
|
||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
### PR Workflow (Review vs Land)
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||||
@@ -74,14 +70,12 @@
|
|||||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Vocabulary: "makeup" = "mac app".
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
@@ -116,14 +110,6 @@
|
|||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
## NPM + 1Password (publish/verify)
|
|
||||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
|
||||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
|
||||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
|
||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
|
||||||
- Kill the tmux session after publish.
|
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
|
|||||||
+26
-212
@@ -1,229 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## 2025.1.12 (Unreleased)
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
|
||||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
|
||||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
|
||||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
|
||||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
|
||||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
|
||||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
|
||||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
|
||||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
|
||||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
|
||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
|
||||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
|
||||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
|
||||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
|
||||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
|
||||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
|
||||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
|
||||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
|
||||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
|
||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
|
||||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
|
||||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
|
||||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
|
||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
|
||||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
|
||||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
|
||||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
|
||||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
|
||||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
|
||||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
|
||||||
|
|
||||||
## 2026.1.14-1
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
|
||||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
|
||||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
|
||||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
|
||||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
|
||||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
|
||||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
|
||||||
- Security: add `SECURITY.md` reporting policy.
|
|
||||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
|
||||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
|
||||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
|
||||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
|
||||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
|
||||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
|
||||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
|
||||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
|
||||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
|
||||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
|
||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
|
||||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
|
||||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
|
||||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
|
||||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
|
||||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
|
||||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
|
||||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
|
||||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
|
||||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
|
||||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
|
||||||
|
|
||||||
## 2026.1.14
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Usage: add MiniMax coding plan usage tracking.
|
|
||||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
|
||||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
|
||||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
|
||||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
|
||||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
|
||||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
|
||||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
|
||||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
|
||||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
|
||||||
|
|
||||||
#### Agents / Auth / Tools / Sandbox
|
|
||||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
|
||||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
|
||||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
|
||||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
|
||||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
|
||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
|
||||||
|
|
||||||
#### macOS / Apps
|
|
||||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
|
||||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
|
||||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
|
||||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
|
||||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
|
||||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
|
||||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
|
||||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
|
||||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
|
||||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
|
||||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
|
||||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
|
||||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
|
||||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
|
||||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
|
||||||
|
|
||||||
## 2026.1.13
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
|
|
||||||
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
|
|
||||||
|
|
||||||
## 2026.1.12-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
|
||||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
|
||||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
|
||||||
|
|
||||||
## 2026.1.12-1
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
|
|
||||||
|
|
||||||
## 2026.1.12
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
|
|
||||||
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
||||||
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
|
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
|
||||||
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
||||||
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
|
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
|
||||||
|
|
||||||
### New & Improved
|
### New & Improved
|
||||||
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
|
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
|
||||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
||||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
||||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
|
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`.
|
||||||
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
|
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
|
||||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
|
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides.
|
||||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
|
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
|
||||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
|
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs).
|
||||||
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
|
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
|
||||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
|
||||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||||
|
|
||||||
### Installer
|
|
||||||
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles.
|
||||||
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
|
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors.
|
||||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
|
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
|
||||||
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
|
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
|
||||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
|
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
|
||||||
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
|
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
|
||||||
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
|
- Auto-reply: explain how to enable `/bash` when it’s disabled; add security notes + FAQ. (#722) — thanks @vrknetha.
|
||||||
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
|
|
||||||
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
|
|
||||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
|
|
||||||
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
|
|
||||||
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
|
|
||||||
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
|
|
||||||
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
||||||
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
|
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool.
|
||||||
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
|
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
|
||||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
|
- Slack: accept slash commands with or without leading `/` for custom command configs.
|
||||||
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
|
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
|
||||||
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
|
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates.
|
||||||
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
|
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom.
|
||||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)".
|
||||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
- Connections UI: polish multi-account account cards.
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||||
@@ -469,7 +283,7 @@
|
|||||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||||
|
|
||||||
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||||
|
|
||||||
## 2026.1.8
|
## 2026.1.8
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
|
|||||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||||
|
|
||||||
- **Shadow** - Discord + Slack subsystem
|
- **Shadow** - Discord + Slack subsystem
|
||||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||||
|
|
||||||
- **Jos** - Telegram, API, Nix mode
|
- **Jos** - Telegram, API, Nix mode
|
||||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||||
|
|||||||
+1
-1
Submodule Peekaboo updated: 5c195f5e46...c1243a7978.
@@ -16,13 +16,13 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||||
|
|
||||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||||
Works with npm, pnpm, or bun.
|
Works with npm, pnpm, or bun.
|
||||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
|||||||
|
|
||||||
Runtime: **Node ≥22**.
|
Runtime: **Node ≥22**.
|
||||||
|
|
||||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot onboard --install-daemon
|
clawdbot onboard --install-daemon
|
||||||
@@ -97,17 +97,17 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
|||||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
|
||||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||||
|
|
||||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||||
@@ -127,9 +127,9 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||||
|
|
||||||
### Channels
|
### Providers
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||||
|
|
||||||
### Apps + nodes
|
### Apps + nodes
|
||||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||||
@@ -145,10 +145,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||||
|
|
||||||
### Runtime + safety
|
### Runtime + safety
|
||||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
- [Provider routing](https://docs.clawd.bot/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/troubleshooting).
|
||||||
|
|
||||||
### Ops + packaging
|
### Ops + packaging
|
||||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||||
@@ -204,7 +204,7 @@ Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web sur
|
|||||||
|
|
||||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||||
|
|
||||||
- **Gateway host** runs the exec tool and channel connections by default.
|
- **Gateway host** runs the exec tool and provider connections by default.
|
||||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||||
|
|
||||||
@@ -315,56 +315,52 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
|||||||
|
|
||||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||||
|
|
||||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
### [WhatsApp](https://docs.clawd.bot/providers/whatsapp)
|
||||||
|
|
||||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||||
|
|
||||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
### [Telegram](https://docs.clawd.bot/providers/telegram)
|
||||||
|
|
||||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
telegram: {
|
||||||
telegram: {
|
botToken: "123456:ABCDEF"
|
||||||
botToken: "123456:ABCDEF"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
### [Slack](https://docs.clawd.bot/providers/slack)
|
||||||
|
|
||||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||||
|
|
||||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
### [Discord](https://docs.clawd.bot/providers/discord)
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
discord: {
|
||||||
discord: {
|
token: "1234abcd"
|
||||||
token: "1234abcd"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
### [Signal](https://docs.clawd.bot/providers/signal)
|
||||||
|
|
||||||
- Requires `signal-cli` and a `channels.signal` config section.
|
- Requires `signal-cli` and a `signal` config section.
|
||||||
|
|
||||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
### [iMessage](https://docs.clawd.bot/providers/imessage)
|
||||||
|
|
||||||
- macOS only; Messages must be signed in.
|
- macOS only; Messages must be signed in.
|
||||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||||
|
|
||||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||||
|
|
||||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||||
@@ -399,7 +395,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
|||||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
|
||||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||||
|
|
||||||
## Advanced docs (discovery + control)
|
## Advanced docs (discovery + control)
|
||||||
@@ -468,29 +464,24 @@ AI/vibe-coded PRs welcome! 🤖
|
|||||||
|
|
||||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||||
|
|
||||||
Core contributors:
|
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
|
||||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
|
||||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
|
||||||
|
|
||||||
## Reporting
|
|
||||||
|
|
||||||
- Email: `steipete@gmail.com`
|
|
||||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
|
||||||
|
|
||||||
## Operational Guidance
|
|
||||||
|
|
||||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
|
||||||
|
|
||||||
@@ -34,7 +34,8 @@ extension AttributedString {
|
|||||||
var ranges: [Range<AttributedString.Index>] = []
|
var ranges: [Range<AttributedString.Index>] = []
|
||||||
for wordRange in wordRanges {
|
for wordRange in wordRanges {
|
||||||
if let lastRange = ranges.last,
|
if let lastRange = ranges.last,
|
||||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||||
|
{
|
||||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||||
} else {
|
} else {
|
||||||
ranges.append(wordRange)
|
ranges.append(wordRange)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
|
|||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||||
if let data = try? Data(contentsOf: fileURL),
|
if let data = try? Data(contentsOf: fileURL),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
|||||||
public init(
|
public init(
|
||||||
triggers: [String],
|
triggers: [String],
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
minPostTriggerGap: TimeInterval = 0.45,
|
||||||
minCommandLength: Int = 1) {
|
minCommandLength: Int = 1)
|
||||||
|
{
|
||||||
self.triggers = triggers
|
self.triggers = triggers
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
self.minPostTriggerGap = minPostTriggerGap
|
||||||
self.minCommandLength = minCommandLength
|
self.minCommandLength = minCommandLength
|
||||||
@@ -56,12 +57,6 @@ public enum WakeWordGate {
|
|||||||
let tokens: [String]
|
let tokens: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MatchCandidate {
|
|
||||||
let index: Int
|
|
||||||
let triggerEnd: TimeInterval
|
|
||||||
let gap: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func match(
|
public static func match(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
segments: [WakeWordSegment],
|
segments: [WakeWordSegment],
|
||||||
@@ -73,7 +68,7 @@ public enum WakeWordGate {
|
|||||||
let tokens = normalizeSegments(segments)
|
let tokens = normalizeSegments(segments)
|
||||||
guard !tokens.isEmpty else { return nil }
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
|
||||||
var best: MatchCandidate?
|
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
for trigger in triggerTokens {
|
||||||
let count = trigger.tokens.count
|
let count = trigger.tokens.count
|
||||||
@@ -89,7 +84,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
if let best, i <= best.index { continue }
|
if let best, i <= best.index { continue }
|
||||||
|
|
||||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
best = (i, triggerEnd, gap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
|||||||
subcommands: [
|
subcommands: [
|
||||||
descriptor(for: ServiceInstall.self),
|
descriptor(for: ServiceInstall.self),
|
||||||
descriptor(for: ServiceUninstall.self),
|
descriptor(for: ServiceUninstall.self),
|
||||||
descriptor(for: ServiceStatus.self)
|
descriptor(for: ServiceStatus.self),
|
||||||
])
|
])
|
||||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||||
let setupDesc = descriptor(for: SetupCommand.self)
|
let setupDesc = descriptor(for: SetupCommand.self)
|
||||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
|||||||
startDesc,
|
startDesc,
|
||||||
stopDesc,
|
stopDesc,
|
||||||
restartDesc,
|
restartDesc,
|
||||||
statusDesc
|
statusDesc,
|
||||||
])
|
])
|
||||||
return [root]
|
return [root]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
|||||||
"Label": label,
|
"Label": label,
|
||||||
"ProgramArguments": [executable, "serve"],
|
"ProgramArguments": [executable, "serve"],
|
||||||
"RunAtLoad": true,
|
"RunAtLoad": true,
|
||||||
"KeepAlive": true
|
"KeepAlive": true,
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: plistURL)
|
try data.write(to: plistURL)
|
||||||
|
|||||||
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
|||||||
|
|
||||||
switch first {
|
switch first {
|
||||||
case "swabble":
|
case "swabble":
|
||||||
try await dispatchSwabble(parsed: parsed, path: path)
|
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||||
|
let sub = path[1]
|
||||||
|
switch sub {
|
||||||
|
case "serve":
|
||||||
|
var cmd = ServeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "transcribe":
|
||||||
|
var cmd = TranscribeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "test-hook":
|
||||||
|
var cmd = TestHookCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "mic":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||||
|
let micSub = path[2]
|
||||||
|
if micSub == "list" {
|
||||||
|
var cmd = MicList(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else if micSub == "set" {
|
||||||
|
var cmd = MicSet(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else {
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||||
|
}
|
||||||
|
case "service":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||||
|
let svcSub = path[2]
|
||||||
|
switch svcSub {
|
||||||
|
case "install":
|
||||||
|
var cmd = ServiceInstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "uninstall":
|
||||||
|
var cmd = ServiceUninstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = ServiceStatus()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||||
|
}
|
||||||
|
case "doctor":
|
||||||
|
var cmd = DoctorCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "setup":
|
||||||
|
var cmd = SetupCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "health":
|
||||||
|
var cmd = HealthCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "tail-log":
|
||||||
|
var cmd = TailLogCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "start":
|
||||||
|
var cmd = StartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "stop":
|
||||||
|
var cmd = StopCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "restart":
|
||||||
|
var cmd = RestartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = StatusCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw CommanderProgramError.unknownCommand(first)
|
throw CommanderProgramError.unknownCommand(first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
|
||||||
switch sub {
|
|
||||||
case "mic":
|
|
||||||
try await dispatchMic(parsed: parsed, path: path)
|
|
||||||
case "service":
|
|
||||||
try await dispatchService(path: path)
|
|
||||||
default:
|
|
||||||
let handlers = swabbleHandlers(parsed: parsed)
|
|
||||||
guard let handler = handlers[sub] else {
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
|
||||||
}
|
|
||||||
try await handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
|
||||||
[
|
|
||||||
"serve": {
|
|
||||||
var cmd = ServeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"transcribe": {
|
|
||||||
var cmd = TranscribeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"test-hook": {
|
|
||||||
var cmd = TestHookCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"doctor": {
|
|
||||||
var cmd = DoctorCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"setup": {
|
|
||||||
var cmd = SetupCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
var cmd = HealthCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"tail-log": {
|
|
||||||
var cmd = TailLogCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
var cmd = StartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"stop": {
|
|
||||||
var cmd = StopCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
var cmd = RestartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
var cmd = StatusCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
|
||||||
switch micSub {
|
|
||||||
case "list":
|
|
||||||
var cmd = MicList(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
case "set":
|
|
||||||
var cmd = MicSet(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchService(path: [String]) async throws {
|
|
||||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
|
||||||
switch svcSub {
|
|
||||||
case "install":
|
|
||||||
var cmd = ServiceInstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "uninstall":
|
|
||||||
var cmd = ServiceUninstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "status":
|
|
||||||
var cmd = ServiceStatus()
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
|
||||||
guard path.count > index else {
|
|
||||||
throw CommanderProgramError.missingSubcommand(command: command)
|
|
||||||
}
|
|
||||||
return path[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
let exitCode = await runCLI()
|
let exitCode = await runCLI()
|
||||||
exit(exitCode)
|
exit(exitCode)
|
||||||
|
|||||||
+33
-188
@@ -2,194 +2,6 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
|
||||||
<title>2026.1.14-1</title>
|
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5825</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
|
||||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
|
||||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<h4>Web Tools</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Browser / Control UI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Plugins</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
|
||||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Security</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
|
||||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Onboarding / Daemon</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
|
||||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Auth / Usage / Config</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
|
||||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
|
||||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
|
||||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
|
||||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
|
||||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Docs</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
|
||||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
|
||||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
|
||||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
|
||||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
|
||||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
|
||||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<h4>CLI / Onboarding</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Control UI / TUI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
|
||||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>macOS / Apps</h4>
|
|
||||||
<ul>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
|
||||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels / Messaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
|
||||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
|
||||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
|
||||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
|
||||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
|
||||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
|
||||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
|
||||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
|
||||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
|
||||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
|
||||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
|
||||||
<li>WhatsApp: harden owner command auth.</li>
|
|
||||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Config / Doctor / Packaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
|
||||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.12-2</title>
|
|
||||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5534</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.11-3</title>
|
<title>2026.1.11-3</title>
|
||||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||||
@@ -207,5 +19,38 @@
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.11-2</title>
|
||||||
|
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5210</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
|
||||||
|
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.11-1</title>
|
||||||
|
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5207</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun installs.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -119,7 +119,7 @@ dependencies {
|
|||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
testImplementation("org.robolectric:robolectric:4.16")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
|
||||||
|
|
||||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
@@ -139,7 +138,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
runtime.loadChat(sessionKey)
|
runtime.loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
|||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||||
import com.clawdbot.android.bridge.BridgePairingClient
|
import com.clawdbot.android.bridge.BridgePairingClient
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.LocationCaptureManager
|
import com.clawdbot.android.node.LocationCaptureManager
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
@@ -79,7 +78,7 @@ class NodeRuntime(context: Context) {
|
|||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("message", JsonPrimitive(command))
|
put("message", JsonPrimitive(command))
|
||||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||||
put("deliver", JsonPrimitive(false))
|
put("deliver", JsonPrimitive(false))
|
||||||
}.toString(),
|
}.toString(),
|
||||||
@@ -143,13 +142,12 @@ class NodeRuntime(context: Context) {
|
|||||||
private val session =
|
private val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote ->
|
||||||
_statusText.value = "Connected"
|
_statusText.value = "Connected"
|
||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
applyMainSessionKey(mainSessionKey)
|
|
||||||
scope.launch { refreshBrandingFromGateway() }
|
scope.launch { refreshBrandingFromGateway() }
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
@@ -161,9 +159,6 @@ class NodeRuntime(context: Context) {
|
|||||||
onInvoke = { req ->
|
onInvoke = { req ->
|
||||||
handleInvoke(req.command, req.paramsJson)
|
handleInvoke(req.command, req.paramsJson)
|
||||||
},
|
},
|
||||||
onTlsFingerprint = { stableId, fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||||
@@ -177,31 +172,11 @@ class NodeRuntime(context: Context) {
|
|||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
_mainSessionKey.value = "main"
|
||||||
_mainSessionKey.value = "main"
|
|
||||||
}
|
|
||||||
val mainKey = resolveMainSessionKey()
|
|
||||||
talkMode.setMainSessionKey(mainKey)
|
|
||||||
chat.applyMainSessionKey(mainKey)
|
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyMainSessionKey(candidate: String?) {
|
|
||||||
val trimmed = candidate?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
|
||||||
if (_mainSessionKey.value == trimmed) return
|
|
||||||
_mainSessionKey.value = trimmed
|
|
||||||
talkMode.setMainSessionKey(trimmed)
|
|
||||||
chat.applyMainSessionKey(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveMainSessionKey(): String {
|
|
||||||
val trimmed = _mainSessionKey.value.trim()
|
|
||||||
return if (trimmed.isEmpty()) "main" else trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeNavigateToA2uiOnConnect() {
|
private fun maybeNavigateToA2uiOnConnect() {
|
||||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||||
@@ -492,17 +467,12 @@ class NodeRuntime(context: Context) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
val storedToken = prefs.loadBridgeToken()
|
||||||
val tls = resolveTlsParams(endpoint)
|
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_statusText.value = "Pairing…"
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildPairingHello(token = null),
|
hello = buildPairingHello(token = null),
|
||||||
tls = tls,
|
|
||||||
onTlsFingerprint = { fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
@@ -519,7 +489,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildSessionHello(token = authToken),
|
hello = buildSessionHello(token = authToken),
|
||||||
tls = tls,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,41 +535,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.disconnect()
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
|
||||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
|
||||||
val manual = endpoint.stableId.startsWith("manual|")
|
|
||||||
|
|
||||||
if (hinted) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
|
||||||
allowTOFU = stored == null,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stored.isNullOrBlank()) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = stored,
|
|
||||||
allowTOFU = false,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manual) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = false,
|
|
||||||
expectedFingerprint = null,
|
|
||||||
allowTOFU = true,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val trimmed = payloadJson.trim()
|
val trimmed = payloadJson.trim()
|
||||||
@@ -625,7 +559,7 @@ class NodeRuntime(context: Context) {
|
|||||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
val sessionKey = resolveMainSessionKey()
|
val sessionKey = "main"
|
||||||
val message =
|
val message =
|
||||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName = name,
|
actionName = name,
|
||||||
@@ -673,9 +607,8 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
chat.load(sessionKey)
|
||||||
chat.load(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChat() {
|
fun refreshChat() {
|
||||||
@@ -768,7 +701,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||||
applyMainSessionKey(mainKey)
|
_mainSessionKey.value = mainKey
|
||||||
|
|
||||||
val parsed = parseHexColorArgb(raw)
|
val parsed = parseHexColorArgb(raw)
|
||||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
|||||||
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadOrCreateInstanceId(): String {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
|||||||
@@ -4,10 +4,3 @@ internal fun normalizeMainKey(raw: String?): String {
|
|||||||
val trimmed = raw?.trim()
|
val trimmed = raw?.trim()
|
||||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return false
|
|
||||||
if (trimmed == "global") return true
|
|
||||||
return trimmed.startsWith("agent:")
|
|
||||||
}
|
|
||||||
@@ -143,8 +143,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||||
val bridgePort = txtInt(resolved, "bridgePort")
|
val bridgePort = txtInt(resolved, "bridgePort")
|
||||||
val canvasPort = txtInt(resolved, "canvasPort")
|
val canvasPort = txtInt(resolved, "canvasPort")
|
||||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
|
||||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
|
||||||
val id = stableId(serviceName, "local.")
|
val id = stableId(serviceName, "local.")
|
||||||
localById[id] =
|
localById[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -157,8 +155,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
@@ -213,11 +209,6 @@ class BridgeDiscovery(
|
|||||||
return txt(info, key)?.toIntOrNull()
|
return txt(info, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
|
||||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -261,8 +252,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
|
||||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
|
||||||
val id = stableId(instanceName, domain)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] =
|
next[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -275,8 +264,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +474,6 @@ class BridgeDiscovery(
|
|||||||
return txtValue(records, key)?.toIntOrNull()
|
return txtValue(records, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
|
||||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
|
|||||||
val gatewayPort: Int? = null,
|
val gatewayPort: Int? = null,
|
||||||
val bridgePort: Int? = null,
|
val bridgePort: Int? = null,
|
||||||
val canvasPort: Int? = null,
|
val canvasPort: Int? = null,
|
||||||
val tlsEnabled: Boolean = false,
|
|
||||||
val tlsFingerprintSha256: String? = null,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
|
|||||||
name = "$host:$port",
|
name = "$host:$port",
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
|
||||||
tlsFingerprintSha256 = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+77
-101
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
class BridgePairingClient {
|
class BridgePairingClient {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -32,120 +33,95 @@ class BridgePairingClient {
|
|||||||
|
|
||||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
suspend fun pairAndHello(
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams? = null,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
|
||||||
): PairResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
socket.tcpNoDelay = true
|
||||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
try {
|
||||||
} catch (e: Exception) {
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
if (tls.required) throw e
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pairAndHelloWithTls(
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams?,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)?,
|
|
||||||
): PairResult {
|
|
||||||
val socket =
|
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
|
||||||
try {
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 60_000
|
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
sendJson(
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
fun send(line: String) {
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
writer.write(line)
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
writer.write("\n")
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
writer.flush()
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
}
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
sendJson(
|
while (true) {
|
||||||
buildJsonObject {
|
val nextLine = reader.readLine() ?: break
|
||||||
put("type", JsonPrimitive("hello"))
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
when (next["type"].asStringOrNull()) {
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
"pair-ok" -> {
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
val token = next["token"].asStringOrNull()
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
}
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
"error" -> {
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
|
||||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
return when (firstObj["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
|
||||||
"error" -> {
|
|
||||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
|
||||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
|
||||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("pair-request"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val nextLine = reader.readLine() ?: break
|
|
||||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
|
||||||
when (next["type"].asStringOrNull()) {
|
|
||||||
"pair-ok" -> {
|
|
||||||
val token = next["token"].asStringOrNull()
|
|
||||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
|
||||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
}
|
}
|
||||||
PairResult(ok = false, token = null, error = "pairing failed")
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||||
|
PairResult(ok = false, token = null, error = message)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
|
||||||
return PairResult(ok = false, token = null, error = message)
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
socket.close()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
|
|
||||||
class BridgeSession(
|
class BridgeSession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
data class Hello(
|
data class Hello(
|
||||||
val nodeId: String,
|
val nodeId: String,
|
||||||
@@ -65,19 +64,12 @@ class BridgeSession(
|
|||||||
private val writeLock = Mutex()
|
private val writeLock = Mutex()
|
||||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
@Volatile private var canvasHostUrl: String? = null
|
@Volatile private var canvasHostUrl: String? = null
|
||||||
@Volatile private var mainSessionKey: String? = null
|
|
||||||
|
|
||||||
private data class DesiredConnection(
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
val endpoint: BridgeEndpoint,
|
|
||||||
val hello: Hello,
|
|
||||||
val tls: BridgeTlsParams?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var desired: DesiredConnection? = null
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
desired = DesiredConnection(endpoint, hello, tls)
|
desired = endpoint to hello
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
@@ -85,7 +77,7 @@ class BridgeSession(
|
|||||||
|
|
||||||
suspend fun updateHello(hello: Hello) {
|
suspend fun updateHello(hello: Hello) {
|
||||||
val target = desired ?: return
|
val target = desired ?: return
|
||||||
desired = target.copy(hello = hello)
|
desired = target.first to hello
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
conn.sendJson(buildHelloJson(hello))
|
conn.sendJson(buildHelloJson(hello))
|
||||||
}
|
}
|
||||||
@@ -98,13 +90,11 @@ class BridgeSession(
|
|||||||
job?.cancelAndJoin()
|
job?.cancelAndJoin()
|
||||||
job = null
|
job = null
|
||||||
canvasHostUrl = null
|
canvasHostUrl = null
|
||||||
mainSessionKey = null
|
|
||||||
onDisconnected("Offline")
|
onDisconnected("Offline")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||||
fun currentMainSessionKey(): String? = mainSessionKey
|
|
||||||
|
|
||||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
@@ -172,10 +162,10 @@ class BridgeSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val (endpoint, hello, tls) = target
|
val (endpoint, hello) = target
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
connectOnce(endpoint, hello, tls)
|
connectOnce(endpoint, hello)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -199,76 +189,58 @@ class BridgeSession(
|
|||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
socket.tcpNoDelay = true
|
||||||
connectWithSocket(endpoint, hello, tls)
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
return@withContext
|
socket.soTimeout = 0
|
||||||
} catch (err: Throwable) {
|
|
||||||
if (tls.required) throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connectWithSocket(endpoint, hello, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
val socket =
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 0
|
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
currentConnection = conn
|
||||||
|
|
||||||
val conn = Connection(socket, reader, writer, writeLock)
|
try {
|
||||||
currentConnection = conn
|
conn.sendJson(buildHelloJson(hello))
|
||||||
|
|
||||||
try {
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
conn.sendJson(buildHelloJson(hello))
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
when (first["type"].asStringOrNull()) {
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
"hello-ok" -> {
|
||||||
?: throw IllegalStateException("unexpected bridge response")
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
when (first["type"].asStringOrNull()) {
|
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||||
"hello-ok" -> {
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
if (BuildConfig.DEBUG) {
|
||||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
runCatching {
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
android.util.Log.d(
|
||||||
mainSessionKey = rawMainSessionKey
|
"ClawdbotBridge",
|
||||||
if (BuildConfig.DEBUG) {
|
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
)
|
||||||
runCatching {
|
}
|
||||||
android.util.Log.d(
|
|
||||||
"ClawdbotBridge",
|
|
||||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onConnected(name, conn.remoteAddress)
|
||||||
}
|
}
|
||||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
}
|
}
|
||||||
"error" -> {
|
|
||||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
|
||||||
throw IllegalStateException("$code: $msg")
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException("unexpected bridge response")
|
|
||||||
}
|
|
||||||
|
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
when (frame["type"].asStringOrNull()) {
|
when (frame["type"].asStringOrNull()) {
|
||||||
"event" -> {
|
"event" -> {
|
||||||
val event = frame["event"].asStringOrNull() ?: continue
|
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
onEvent(event, payload)
|
onEvent(event, payload)
|
||||||
}
|
}
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -314,20 +286,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package com.clawdbot.android.bridge
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
data class BridgeTlsParams(
|
|
||||||
val required: Boolean,
|
|
||||||
val expectedFingerprint: String?,
|
|
||||||
val allowTOFU: Boolean,
|
|
||||||
val stableId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
|
||||||
if (params == null) return Socket()
|
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
|
||||||
val defaultTrust = defaultTrustManager()
|
|
||||||
@SuppressLint("CustomX509TrustManager")
|
|
||||||
val trustManager =
|
|
||||||
object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
defaultTrust.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
|
||||||
val fingerprint = sha256Hex(chain[0].encoded)
|
|
||||||
if (expected != null) {
|
|
||||||
if (fingerprint != expected) {
|
|
||||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (params.allowTOFU) {
|
|
||||||
onStore?.invoke(fingerprint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultTrust.checkServerTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = SSLContext.getInstance("TLS")
|
|
||||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
|
||||||
return context.socketFactory.createSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
|
||||||
if (socket is SSLSocket) {
|
|
||||||
socket.startHandshake()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as java.security.KeyStore?)
|
|
||||||
val trust =
|
|
||||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
|
||||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256Hex(data: ByteArray): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
|
||||||
val out = StringBuilder(digest.size * 2)
|
|
||||||
for (byte in digest) {
|
|
||||||
out.append(String.format("%02x", byte))
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): String {
|
|
||||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
|
||||||
}
|
|
||||||
@@ -71,21 +71,12 @@ class ChatController(
|
|||||||
_sessionId.value = null
|
_sessionId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(sessionKey: String) {
|
fun load(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
val key = sessionKey.trim().ifEmpty { "main" }
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
|
||||||
val trimmed = mainSessionKey.trim()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (_sessionKey.value == trimmed) return
|
|
||||||
if (_sessionKey.value != "main") return
|
|
||||||
_sessionKey.value = trimmed
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import com.clawdbot.android.chat.ChatSessionEntry
|
|||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
sessions: List<ChatSessionEntry>,
|
sessions: List<ChatSessionEntry>,
|
||||||
mainSessionKey: String,
|
|
||||||
healthOk: Boolean,
|
healthOk: Boolean,
|
||||||
thinkingLevel: String,
|
thinkingLevel: String,
|
||||||
pendingRunCount: Int,
|
pendingRunCount: Int,
|
||||||
@@ -62,7 +61,7 @@ fun ChatComposer(
|
|||||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||||
val currentSessionLabel =
|
val currentSessionLabel =
|
||||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
|
||||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
val sessions by viewModel.chatSessions.collectAsState()
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadChat(mainSessionKey)
|
viewModel.loadChat("main")
|
||||||
viewModel.refreshChatSessions(limit = 200)
|
viewModel.refreshChatSessions(limit = 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +85,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
ChatComposer(
|
ChatComposer(
|
||||||
sessionKey = sessionKey,
|
sessionKey = sessionKey,
|
||||||
sessions = sessions,
|
sessions = sessions,
|
||||||
mainSessionKey = mainSessionKey,
|
|
||||||
healthOk = healthOk,
|
healthOk = healthOk,
|
||||||
thinkingLevel = thinkingLevel,
|
thinkingLevel = thinkingLevel,
|
||||||
pendingRunCount = pendingRunCount,
|
pendingRunCount = pendingRunCount,
|
||||||
|
|||||||
@@ -2,23 +2,20 @@ package com.clawdbot.android.ui.chat
|
|||||||
|
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
|
private const val MAIN_SESSION_KEY = "main"
|
||||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||||
|
|
||||||
fun resolveSessionChoices(
|
fun resolveSessionChoices(
|
||||||
currentSessionKey: String,
|
currentSessionKey: String,
|
||||||
sessions: List<ChatSessionEntry>,
|
sessions: List<ChatSessionEntry>,
|
||||||
mainSessionKey: String,
|
|
||||||
nowMs: Long = System.currentTimeMillis(),
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
): List<ChatSessionEntry> {
|
): List<ChatSessionEntry> {
|
||||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
val current = currentSessionKey.trim()
|
||||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
|
||||||
val aliasKey = if (mainKey == "main") null else "main"
|
|
||||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||||
val recent = mutableListOf<ChatSessionEntry>()
|
val recent = mutableListOf<ChatSessionEntry>()
|
||||||
val seen = mutableSetOf<String>()
|
val seen = mutableSetOf<String>()
|
||||||
for (entry in sorted) {
|
for (entry in sorted) {
|
||||||
if (aliasKey != null && entry.key == aliasKey) continue
|
|
||||||
if (!seen.add(entry.key)) continue
|
if (!seen.add(entry.key)) continue
|
||||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||||
recent.add(entry)
|
recent.add(entry)
|
||||||
@@ -26,13 +23,13 @@ fun resolveSessionChoices(
|
|||||||
|
|
||||||
val result = mutableListOf<ChatSessionEntry>()
|
val result = mutableListOf<ChatSessionEntry>()
|
||||||
val included = mutableSetOf<String>()
|
val included = mutableSetOf<String>()
|
||||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||||
if (mainEntry != null) {
|
if (mainEntry != null) {
|
||||||
result.add(mainEntry)
|
result.add(mainEntry)
|
||||||
included.add(mainKey)
|
included.add(MAIN_SESSION_KEY)
|
||||||
} else if (current == mainKey) {
|
} else if (current == MAIN_SESSION_KEY) {
|
||||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||||
included.add(mainKey)
|
included.add(MAIN_SESSION_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (entry in recent) {
|
for (entry in recent) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import android.speech.tts.UtteranceProgressListener
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
|
||||||
import com.clawdbot.android.normalizeMainKey
|
import com.clawdbot.android.normalizeMainKey
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
@@ -117,13 +116,6 @@ class TalkModeManager(
|
|||||||
chatSubscribedSessionKey = null
|
chatSubscribedSessionKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMainSessionKey(sessionKey: String?) {
|
|
||||||
val trimmed = sessionKey?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
|
||||||
mainSessionKey = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEnabled(enabled: Boolean) {
|
fun setEnabled(enabled: Boolean) {
|
||||||
if (_isEnabled.value == enabled) return
|
if (_isEnabled.value == enabled) return
|
||||||
_isEnabled.value = enabled
|
_isEnabled.value = enabled
|
||||||
@@ -835,9 +827,7 @@ class TalkModeManager(
|
|||||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||||
|
|
||||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
mainSessionKey = mainKey
|
||||||
mainSessionKey = mainKey
|
|
||||||
}
|
|
||||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||||
voiceAliases = aliases
|
voiceAliases = aliases
|
||||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
@@ -97,7 +97,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
@@ -167,7 +167,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||||
@@ -239,7 +239,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.countDown() },
|
onConnected = { _, _ -> connected.countDown() },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SessionFiltersTest {
|
|||||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class SessionFiltersTest {
|
|||||||
val recent = now - 10 * 60 * 1000L
|
val recent = now - 10 * 60 * 1000L
|
||||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||||
|
|
||||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||||
assertEquals(listOf("main", "custom"), result)
|
assertEquals(listOf("main", "custom"), result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,36 +10,10 @@ actor BridgeClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onStatus: onStatus)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onStatus: onStatus)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pairAndHelloOnce(
|
|
||||||
endpoint: NWEndpoint,
|
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let params = self.makeParameters(tls: tls)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
@@ -168,18 +142,6 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TimeoutError: LocalizedError, Sendable {
|
private struct TimeoutError: LocalizedError, Sendable {
|
||||||
var purpose: String
|
var purpose: String
|
||||||
var seconds: Int
|
var seconds: Int
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +115,9 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: stableID,
|
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
token: token,
|
||||||
instanceId: instanceId)
|
instanceId: instanceId)
|
||||||
return
|
return
|
||||||
@@ -139,12 +135,10 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
endpoint: target.endpoint,
|
endpoint: target.endpoint,
|
||||||
bridgeStableID: target.stableID,
|
bridgeStableID: target.stableID,
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
token: token,
|
||||||
instanceId: instanceId)
|
instanceId: instanceId)
|
||||||
}
|
}
|
||||||
@@ -188,7 +182,6 @@ final class BridgeConnectionController {
|
|||||||
private func startAutoConnect(
|
private func startAutoConnect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
bridgeStableID: String,
|
bridgeStableID: String,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
token: String,
|
token: String,
|
||||||
instanceId: String)
|
instanceId: String)
|
||||||
{
|
{
|
||||||
@@ -200,7 +193,6 @@ final class BridgeConnectionController {
|
|||||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appModel.bridgeStatusText = status
|
appModel.bridgeStatusText = status
|
||||||
@@ -216,7 +208,6 @@ final class BridgeConnectionController {
|
|||||||
appModel.connectToBridge(
|
appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: bridgeStableID,
|
bridgeStableID: bridgeStableID,
|
||||||
tls: tls,
|
|
||||||
hello: self.makeHello(token: resolvedToken))
|
hello: self.makeHello(token: resolvedToken))
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -226,47 +217,6 @@ final class BridgeConnectionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
|
|||||||
var gatewayPort: Int?
|
var gatewayPort: Int?
|
||||||
var bridgePort: Int?
|
var bridgePort: Int?
|
||||||
var canvasPort: Int?
|
var canvasPort: Int?
|
||||||
var tlsEnabled: Bool
|
|
||||||
var tlsFingerprintSha256: String?
|
|
||||||
var cliPath: String?
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
|
|||||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
|
||||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
|
||||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
|
|||||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||||
return Int(raw)
|
return Int(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ actor BridgeSession {
|
|||||||
|
|
||||||
private(set) var state: State = .idle
|
private(set) var state: State = .idle
|
||||||
private var canvasHostUrl: String?
|
private var canvasHostUrl: String?
|
||||||
private var mainSessionKey: String?
|
|
||||||
|
|
||||||
func currentCanvasHostUrl() -> String? {
|
func currentCanvasHostUrl() -> String? {
|
||||||
self.canvasHostUrl
|
self.canvasHostUrl
|
||||||
@@ -69,42 +68,15 @@ actor BridgeSession {
|
|||||||
func connect(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
async throws
|
async throws
|
||||||
{
|
{
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
do {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectOnce(
|
let params = NWParameters.tcp
|
||||||
endpoint: NWEndpoint,
|
params.includePeerToPeer = true
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
|
||||||
{
|
|
||||||
let params = self.makeParameters(tls: tls)
|
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -135,9 +107,7 @@ actor BridgeSession {
|
|||||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||||
self.state = .connected(serverName: ok.serverName)
|
self.state = .connected(serverName: ok.serverName)
|
||||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
await onConnected?(ok.serverName)
|
||||||
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
|
|
||||||
await onConnected?(ok.serverName, self.mainSessionKey)
|
|
||||||
} else if base.type == "error" {
|
} else if base.type == "error" {
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||||
@@ -247,7 +217,6 @@ actor BridgeSession {
|
|||||||
self.queue = nil
|
self.queue = nil
|
||||||
self.buffer = Data()
|
self.buffer = Data()
|
||||||
self.canvasHostUrl = nil
|
self.canvasHostUrl = nil
|
||||||
self.mainSessionKey = nil
|
|
||||||
|
|
||||||
let pending = self.pendingRPC.values
|
let pending = self.pendingRPC.values
|
||||||
self.pendingRPC.removeAll()
|
self.pendingRPC.removeAll()
|
||||||
@@ -265,10 +234,6 @@ actor BridgeSession {
|
|||||||
self.state = .idle
|
self.state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentMainSessionKey() -> String? {
|
|
||||||
self.mainSessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginRPC(
|
private func beginRPC(
|
||||||
id: String,
|
id: String,
|
||||||
request: BridgeRPCRequest,
|
request: BridgeRPCRequest,
|
||||||
@@ -282,18 +247,6 @@ actor BridgeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func timeoutRPC(id: String) async {
|
private func timeoutRPC(id: String) async {
|
||||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct BridgeTLSParams: Sendable {
|
|
||||||
let required: Bool
|
|
||||||
let expectedFingerprint: String?
|
|
||||||
let allowTOFU: Bool
|
|
||||||
let storeKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BridgeTLSStore {
|
|
||||||
private static let service = "com.clawdbot.bridge.tls"
|
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
|
||||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
|
||||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
|
||||||
guard let params else { return nil }
|
|
||||||
let options = NWProtocolTLS.Options()
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
|
||||||
let allowTOFU = params.allowTOFU
|
|
||||||
let storeKey = params.storeKey
|
|
||||||
|
|
||||||
sec_protocol_options_set_verify_block(
|
|
||||||
options.securityProtocolOptions,
|
|
||||||
{ _, trust, complete in
|
|
||||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
|
||||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
|
||||||
let cert = chain.first
|
|
||||||
{
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
|
||||||
let fingerprint = sha256Hex(data)
|
|
||||||
if let expected {
|
|
||||||
complete(fingerprint == expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if allowTOFU {
|
|
||||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
|
||||||
complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
|
||||||
complete(ok)
|
|
||||||
},
|
|
||||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
@@ -190,7 +190,14 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
return Self.discoverVideoDevices().map { device in
|
let types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
]
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices.map { device in
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -225,7 +232,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,24 +252,6 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
|
||||||
let types: [AVCaptureDevice.DeviceType] = [
|
|
||||||
.builtInWideAngleCamera,
|
|
||||||
.builtInUltraWideCamera,
|
|
||||||
.builtInTelephotoCamera,
|
|
||||||
.builtInDualCamera,
|
|
||||||
.builtInDualWideCamera,
|
|
||||||
.builtInTripleCamera,
|
|
||||||
.builtInTrueDepthCamera,
|
|
||||||
.builtInLiDARDepthCamera,
|
|
||||||
]
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: types,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified)
|
|
||||||
return session.devices
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
let q = quality ?? 0.9
|
let q = quality ?? 0.9
|
||||||
return min(1.0, max(0.05, q))
|
return min(1.0, max(0.05, q))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct ChatSheet: View {
|
|||||||
@State private var viewModel: ClawdbotChatViewModel
|
@State private var viewModel: ClawdbotChatViewModel
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
|
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
self._viewModel = State(
|
self._viewModel = State(
|
||||||
initialValue: ClawdbotChatViewModel(
|
initialValue: ClawdbotChatViewModel(
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ final class NodeAppModel {
|
|||||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||||
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
let sessionKey = self.mainSessionKey
|
let sessionKey = "main"
|
||||||
|
|
||||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||||
actionName: name,
|
actionName: name,
|
||||||
@@ -205,7 +205,6 @@ final class NodeAppModel {
|
|||||||
func connectToBridge(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
bridgeStableID: String,
|
bridgeStableID: String,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
{
|
{
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
@@ -233,16 +232,12 @@ final class NodeAppModel {
|
|||||||
try await self.bridge.connect(
|
try await self.bridge.connect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
onConnected: { [weak self] serverName in
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeStatusText = "Connected"
|
self.bridgeStatusText = "Connected"
|
||||||
self.bridgeServerName = serverName
|
self.bridgeServerName = serverName
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
|
||||||
self.applyMainSessionKey(mainSessionKey)
|
|
||||||
}
|
|
||||||
if let addr = await self.bridge.currentRemoteAddress() {
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
@@ -291,10 +286,7 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.seamColorHex = nil
|
self.seamColorHex = nil
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = "main"
|
||||||
self.mainSessionKey = "main"
|
|
||||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
|
||||||
}
|
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,23 +303,10 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.seamColorHex = nil
|
self.seamColorHex = nil
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = "main"
|
||||||
self.mainSessionKey = "main"
|
|
||||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
|
||||||
}
|
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyMainSessionKey(_ key: String?) {
|
|
||||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if SessionKey.isCanonicalMainSessionKey(current) { return }
|
|
||||||
if trimmed == current { return }
|
|
||||||
self.mainSessionKey = trimmed
|
|
||||||
self.talkMode.updateMainSessionKey(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
var seamColor: Color {
|
var seamColor: Color {
|
||||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||||
}
|
}
|
||||||
@@ -356,10 +335,7 @@ final class NodeAppModel {
|
|||||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = mainKey
|
||||||
self.mainSessionKey = mainKey
|
|
||||||
self.talkMode.updateMainSessionKey(mainKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -137,11 +137,9 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
{
|
{
|
||||||
{ sample, type, error in
|
{ sample, type, error in
|
||||||
let sampleBox = UncheckedSendableBox(value: sample)
|
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
// Serialize writes to avoid queue asserts.
|
// Serialize writes to avoid queue asserts.
|
||||||
recordQueue.async {
|
recordQueue.async {
|
||||||
let sample = sampleBox.value
|
|
||||||
if let error {
|
if let error {
|
||||||
state.withLock { state in
|
state.withLock { state in
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
|||||||
@@ -5,11 +5,4 @@ enum SessionKey {
|
|||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return trimmed.isEmpty ? "main" : trimmed
|
return trimmed.isEmpty ? "main" : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
|
||||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty { return false }
|
|
||||||
if trimmed == "global" { return true }
|
|
||||||
return trimmed.hasPrefix("agent:")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -407,11 +407,9 @@ struct SettingsTab: View {
|
|||||||
modelIdentifier: self.modelIdentifier(),
|
modelIdentifier: self.modelIdentifier(),
|
||||||
caps: self.currentCaps(),
|
caps: self.currentCaps(),
|
||||||
commands: self.currentCommands())
|
commands: self.currentCommands())
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
|
||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -428,7 +426,6 @@ struct SettingsTab: View {
|
|||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
bridgeStableID: bridge.stableID,
|
bridgeStableID: bridge.stableID,
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -465,8 +462,6 @@ struct SettingsTab: View {
|
|||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
let statusStore = self.connectStatus
|
||||||
@@ -490,7 +485,6 @@ struct SettingsTab: View {
|
|||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -506,8 +500,7 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: stableID,
|
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -524,47 +517,6 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
|||||||
@@ -53,13 +53,6 @@ final class TalkModeManager: NSObject {
|
|||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMainSessionKey(_ sessionKey: String?) {
|
|
||||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
|
|
||||||
self.mainSessionKey = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func setEnabled(_ enabled: Bool) {
|
func setEnabled(_ enabled: Bool) {
|
||||||
self.isEnabled = enabled
|
self.isEnabled = enabled
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -656,10 +649,7 @@ final class TalkModeManager: NSObject {
|
|||||||
guard let config = json["config"] as? [String: Any] else { return }
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
let talk = config["talk"] as? [String: Any]
|
let talk = config["talk"] as? [String: Any]
|
||||||
let session = config["session"] as? [String: Any]
|
let session = config["session"] as? [String: Any]
|
||||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
|
||||||
self.mainSessionKey = mainKey
|
|
||||||
}
|
|
||||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||||
var resolved: [String: String] = [:]
|
var resolved: [String: String] = [:]
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
{
|
{
|
||||||
self.lastToken = hello.token
|
self.lastToken = hello.token
|
||||||
@@ -245,8 +244,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||||
let account = "bridge-token.ios-test"
|
let account = "bridge-token.ios-test"
|
||||||
@@ -295,8 +292,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
name: "Gateway B",
|
name: "Gateway B",
|
||||||
@@ -308,8 +303,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 28789,
|
gatewayPort: 28789,
|
||||||
bridgePort: 28790,
|
bridgePort: 28790,
|
||||||
canvasPort: 28793,
|
canvasPort: 28793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
|
|
||||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.clawdbot
|
bundleIdPrefix: com.clawdbot
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "18.0"
|
iOS: "17.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
@@ -73,15 +73,6 @@
|
|||||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-concurrency-extras",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
|
||||||
"version" : "1.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-configuration",
|
"identity" : "swift-configuration",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -153,24 +144,6 @@
|
|||||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||||
"version" : "1.6.3"
|
"version" : "1.6.3"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swiftui-math",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
|
||||||
"version" : "0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "textual",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/textual",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
|
||||||
"version" : "0.2.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -170,10 +170,6 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemRunPolicy: SystemRunPolicy {
|
|
||||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||||
var canvasPanelVisible: Bool = false
|
var canvasPanelVisible: Bool = false
|
||||||
|
|
||||||
@@ -296,7 +292,6 @@ final class AppState {
|
|||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
self.systemRunPolicy = SystemRunPolicy.load()
|
|
||||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
@@ -341,15 +336,6 @@ final class AppState {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sanitizeSSHTarget(_ value: String) -> String {
|
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.hasPrefix("ssh ") {
|
|
||||||
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startConfigWatcher() {
|
private func startConfigWatcher() {
|
||||||
let configUrl = ClawdbotConfigFile.url()
|
let configUrl = ClawdbotConfigFile.url()
|
||||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||||
@@ -415,7 +401,6 @@ final class AppState {
|
|||||||
|
|
||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -445,46 +430,15 @@ final class AppState {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if connectionMode == .remote {
|
if connectionMode == .remote, let host = remoteHost {
|
||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
if let host = remoteHost {
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
let existingUrl = (remote["url"] as? String)?
|
let port = parsedExisting?.port ?? 18789
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
if existingUrl != desiredUrl {
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
remote["url"] = desiredUrl
|
||||||
let port = parsedExisting?.port ?? 18789
|
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
|
||||||
if existingUrl != desiredUrl {
|
|
||||||
remote["url"] = desiredUrl
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
|
||||||
if !sanitizedTarget.isEmpty {
|
|
||||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
|
||||||
remote["sshTarget"] = sanitizedTarget
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshTarget"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshTarget")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmedIdentity.isEmpty {
|
|
||||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
|
||||||
remote["sshIdentity"] = trimmedIdentity
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
} else if remote["sshIdentity"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshIdentity")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteChanged {
|
|
||||||
gateway["remote"] = remote
|
gateway["remote"] = remote
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,12 +282,7 @@ actor BridgeConnectionHandler {
|
|||||||
do {
|
do {
|
||||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||||
self.isAuthenticated = true
|
self.isAuthenticated = true
|
||||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||||
try await self.send(
|
|
||||||
BridgeHelloOk(
|
|
||||||
type: "hello-ok",
|
|
||||||
serverName: serverName,
|
|
||||||
mainSessionKey: mainSessionKey))
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -303,12 +298,7 @@ actor BridgeConnectionHandler {
|
|||||||
case .ok:
|
case .ok:
|
||||||
self.isAuthenticated = true
|
self.isAuthenticated = true
|
||||||
do {
|
do {
|
||||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||||
try await self.send(
|
|
||||||
BridgeHelloOk(
|
|
||||||
type: "hello-ok",
|
|
||||||
serverName: serverName,
|
|
||||||
mainSessionKey: mainSessionKey))
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ actor BridgeServer {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last))
|
provider: .last))
|
||||||
|
|
||||||
case "agent.request":
|
case "agent.request":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
@@ -205,7 +205,7 @@ actor BridgeServer {
|
|||||||
?? "node-\(nodeId)"
|
?? "node-\(nodeId)"
|
||||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let provider = GatewayAgentProvider(raw: link.channel)
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: message,
|
message: message,
|
||||||
@@ -213,7 +213,7 @@ actor BridgeServer {
|
|||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: link.deliver,
|
deliver: link.deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel))
|
provider: provider))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last,
|
provider: .last,
|
||||||
idempotencyKey: actionId))
|
idempotencyKey: actionId))
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ enum ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let overrideStore = OverrideStore()
|
private static let overrideStore = OverrideStore()
|
||||||
@MainActor private static var lastHash: String?
|
|
||||||
|
|
||||||
private static func isRemoteMode() async -> Bool {
|
private static func isRemoteMode() async -> Bool {
|
||||||
let overrides = await self.overrideStore.overrides
|
let overrides = await self.overrideStore.overrides
|
||||||
@@ -76,7 +75,6 @@ enum ConfigStore {
|
|||||||
method: .configGet,
|
method: .configGet,
|
||||||
params: nil,
|
params: nil,
|
||||||
timeoutMs: 8000)
|
timeoutMs: 8000)
|
||||||
self.lastHash = snap.hash
|
|
||||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
@@ -85,24 +83,17 @@ enum ConfigStore {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||||
if self.lastHash == nil {
|
|
||||||
_ = await self.loadFromGateway()
|
|
||||||
}
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||||
if let baseHash = self.lastHash {
|
|
||||||
params["baseHash"] = AnyCodable(baseHash)
|
|
||||||
}
|
|
||||||
_ = try await GatewayConnection.shared.requestRaw(
|
_ = try await GatewayConnection.shared.requestRaw(
|
||||||
method: .configSet,
|
method: .configSet,
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 10000)
|
timeoutMs: 10000)
|
||||||
_ = await self.loadFromGateway()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
+3
-3
@@ -11,9 +11,9 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
|
func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if channel == .whatsapp {
|
if provider == .whatsapp {
|
||||||
Button("Logout") {
|
Button("Logout") {
|
||||||
Task { await self.store.logoutWhatsApp() }
|
Task { await self.store.logoutWhatsApp() }
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
|
|||||||
.disabled(self.store.whatsappBusy)
|
.disabled(self.store.whatsappBusy)
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel == .telegram {
|
if provider == .telegram {
|
||||||
Button("Logout") {
|
Button("Logout") {
|
||||||
Task { await self.store.logoutTelegram() }
|
Task { await self.store.logoutTelegram() }
|
||||||
}
|
}
|
||||||
+62
-62
@@ -1,15 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension ConnectionsSettings {
|
extension ConnectionsSettings {
|
||||||
private func channelStatus<T: Decodable>(
|
private func providerStatus<T: Decodable>(
|
||||||
_ id: String,
|
_ id: String,
|
||||||
as type: T.Type) -> T?
|
as type: T.Type) -> T?
|
||||||
{
|
{
|
||||||
self.store.snapshot?.decodeChannel(id, as: type)
|
self.store.snapshot?.decodeProvider(id, as: type)
|
||||||
}
|
}
|
||||||
|
|
||||||
var whatsAppTint: Color {
|
var whatsAppTint: Color {
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
if !status.configured { return .secondary }
|
||||||
if !status.linked { return .red }
|
if !status.linked { return .red }
|
||||||
@@ -20,7 +20,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var telegramTint: Color {
|
var telegramTint: Color {
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
if !status.configured { return .secondary }
|
||||||
if status.lastError != nil { return .orange }
|
if status.lastError != nil { return .orange }
|
||||||
@@ -30,7 +30,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var discordTint: Color {
|
var discordTint: Color {
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
if !status.configured { return .secondary }
|
||||||
if status.lastError != nil { return .orange }
|
if status.lastError != nil { return .orange }
|
||||||
@@ -40,7 +40,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signalTint: Color {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
if !status.configured { return .secondary }
|
||||||
if status.lastError != nil { return .orange }
|
if status.lastError != nil { return .orange }
|
||||||
@@ -50,7 +50,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var imessageTint: Color {
|
var imessageTint: Color {
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
if !status.configured { return .secondary }
|
if !status.configured { return .secondary }
|
||||||
if status.lastError != nil { return .orange }
|
if status.lastError != nil { return .orange }
|
||||||
@@ -60,7 +60,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var whatsAppSummary: String {
|
var whatsAppSummary: String {
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.linked { return "Not linked" }
|
if !status.linked { return "Not linked" }
|
||||||
if status.connected { return "Connected" }
|
if status.connected { return "Connected" }
|
||||||
@@ -69,7 +69,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var telegramSummary: String {
|
var telegramSummary: String {
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
if !status.configured { return "Not configured" }
|
||||||
if status.running { return "Running" }
|
if status.running { return "Running" }
|
||||||
@@ -77,7 +77,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var discordSummary: String {
|
var discordSummary: String {
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
if !status.configured { return "Not configured" }
|
||||||
if status.running { return "Running" }
|
if status.running { return "Running" }
|
||||||
@@ -85,7 +85,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signalSummary: String {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
if !status.configured { return "Not configured" }
|
||||||
if status.running { return "Running" }
|
if status.running { return "Running" }
|
||||||
@@ -93,7 +93,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var imessageSummary: String {
|
var imessageSummary: String {
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
if !status.configured { return "Not configured" }
|
if !status.configured { return "Not configured" }
|
||||||
if status.running { return "Running" }
|
if status.running { return "Running" }
|
||||||
@@ -101,7 +101,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var whatsAppDetails: String? {
|
var whatsAppDetails: String? {
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
||||||
@@ -132,7 +132,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var telegramDetails: String? {
|
var telegramDetails: String? {
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
if let source = status.tokenSource {
|
if let source = status.tokenSource {
|
||||||
@@ -164,7 +164,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var discordDetails: String? {
|
var discordDetails: String? {
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
if let source = status.tokenSource {
|
if let source = status.tokenSource {
|
||||||
@@ -193,7 +193,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signalDetails: String? {
|
var signalDetails: String? {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
lines.append("Base URL: \(status.baseUrl)")
|
lines.append("Base URL: \(status.baseUrl)")
|
||||||
@@ -220,7 +220,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var imessageDetails: String? {
|
var imessageDetails: String? {
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
||||||
@@ -243,68 +243,68 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isTelegramTokenLocked: Bool {
|
var isTelegramTokenLocked: Bool {
|
||||||
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDiscordTokenLocked: Bool {
|
var isDiscordTokenLocked: Bool {
|
||||||
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ConnectionChannel] {
|
var orderedProviders: [ConnectionProvider] {
|
||||||
ConnectionChannel.allCases.sorted { lhs, rhs in
|
ConnectionProvider.allCases.sorted { lhs, rhs in
|
||||||
let lhsEnabled = self.channelEnabled(lhs)
|
let lhsEnabled = self.providerEnabled(lhs)
|
||||||
let rhsEnabled = self.channelEnabled(rhs)
|
let rhsEnabled = self.providerEnabled(rhs)
|
||||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||||
return lhs.sortOrder < rhs.sortOrder
|
return lhs.sortOrder < rhs.sortOrder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabledChannels: [ConnectionChannel] {
|
var enabledProviders: [ConnectionProvider] {
|
||||||
self.orderedChannels.filter { self.channelEnabled($0) }
|
self.orderedProviders.filter { self.providerEnabled($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableChannels: [ConnectionChannel] {
|
var availableProviders: [ConnectionProvider] {
|
||||||
self.orderedChannels.filter { !self.channelEnabled($0) }
|
self.orderedProviders.filter { !self.providerEnabled($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureSelection() {
|
func ensureSelection() {
|
||||||
guard let selected = self.selectedChannel else {
|
guard let selected = self.selectedProvider else {
|
||||||
self.selectedChannel = self.orderedChannels.first
|
self.selectedProvider = self.orderedProviders.first
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !self.orderedChannels.contains(selected) {
|
if !self.orderedProviders.contains(selected) {
|
||||||
self.selectedChannel = self.orderedChannels.first
|
self.selectedProvider = self.orderedProviders.first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
|
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.configured || status.linked || status.running
|
return status.configured || status.linked || status.running
|
||||||
case .telegram:
|
case .telegram:
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.configured || status.running
|
return status.configured || status.running
|
||||||
case .discord:
|
case .discord:
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.configured || status.running
|
return status.configured || status.running
|
||||||
case .signal:
|
case .signal:
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.configured || status.running
|
return status.configured || status.running
|
||||||
case .imessage:
|
case .imessage:
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.configured || status.running
|
return status.configured || status.running
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func channelSection(_ channel: ConnectionChannel) -> some View {
|
func providerSection(_ provider: ConnectionProvider) -> some View {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
self.whatsAppSection
|
self.whatsAppSection
|
||||||
case .telegram:
|
case .telegram:
|
||||||
@@ -318,8 +318,8 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelTint(_ channel: ConnectionChannel) -> Color {
|
func providerTint(_ provider: ConnectionProvider) -> Color {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
self.whatsAppTint
|
self.whatsAppTint
|
||||||
case .telegram:
|
case .telegram:
|
||||||
@@ -333,8 +333,8 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelSummary(_ channel: ConnectionChannel) -> String {
|
func providerSummary(_ provider: ConnectionProvider) -> String {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
self.whatsAppSummary
|
self.whatsAppSummary
|
||||||
case .telegram:
|
case .telegram:
|
||||||
@@ -348,8 +348,8 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelDetails(_ channel: ConnectionChannel) -> String? {
|
func providerDetails(_ provider: ConnectionProvider) -> String? {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
self.whatsAppDetails
|
self.whatsAppDetails
|
||||||
case .telegram:
|
case .telegram:
|
||||||
@@ -363,55 +363,55 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
|
func providerLastCheckText(_ provider: ConnectionProvider) -> String {
|
||||||
guard let date = self.channelLastCheck(channel) else { return "never" }
|
guard let date = self.providerLastCheck(provider) else { return "never" }
|
||||||
return relativeAge(from: date)
|
return relativeAge(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
|
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||||
case .telegram:
|
case .telegram:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case .discord:
|
case .discord:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case .signal:
|
case .signal:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
case .imessage:
|
case .imessage:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelHasError(_ channel: ConnectionChannel) -> Bool {
|
func providerHasError(_ provider: ConnectionProvider) -> Bool {
|
||||||
switch channel {
|
switch provider {
|
||||||
case .whatsapp:
|
case .whatsapp:
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||||
case .telegram:
|
case .telegram:
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case .discord:
|
case .discord:
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case .signal:
|
case .signal:
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case .imessage:
|
case .imessage:
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ extension ConnectionsSettings {
|
|||||||
self.store.start()
|
self.store.start()
|
||||||
self.ensureSelection()
|
self.ensureSelection()
|
||||||
}
|
}
|
||||||
.onChange(of: self.orderedChannels) { _, _ in
|
.onChange(of: self.orderedProviders) { _, _ in
|
||||||
self.ensureSelection()
|
self.ensureSelection()
|
||||||
}
|
}
|
||||||
.onDisappear { self.store.stop() }
|
.onDisappear { self.store.stop() }
|
||||||
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
|
|||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
if !self.enabledChannels.isEmpty {
|
if !self.enabledProviders.isEmpty {
|
||||||
self.sidebarSectionHeader("Configured")
|
self.sidebarSectionHeader("Configured")
|
||||||
ForEach(self.enabledChannels) { channel in
|
ForEach(self.enabledProviders) { provider in
|
||||||
self.sidebarRow(channel)
|
self.sidebarRow(provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.availableChannels.isEmpty {
|
if !self.availableProviders.isEmpty {
|
||||||
self.sidebarSectionHeader("Available")
|
self.sidebarSectionHeader("Available")
|
||||||
ForEach(self.availableChannels) { channel in
|
ForEach(self.availableProviders) { provider in
|
||||||
self.sidebarRow(channel)
|
self.sidebarRow(provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
|
|||||||
|
|
||||||
private var detail: some View {
|
private var detail: some View {
|
||||||
Group {
|
Group {
|
||||||
if let channel = self.selectedChannel {
|
if let provider = self.selectedProvider {
|
||||||
self.channelDetail(channel)
|
self.providerDetail(provider)
|
||||||
} else {
|
} else {
|
||||||
self.emptyDetail
|
self.emptyDetail
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Connections")
|
Text("Connections")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("Select a channel to view status and settings.")
|
Text("Select a provider to view status and settings.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
|
|||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelDetail(_ channel: ConnectionChannel) -> some View {
|
private func providerDetail(_ provider: ConnectionProvider) -> some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
self.detailHeader(for: channel)
|
self.detailHeader(for: provider)
|
||||||
Divider()
|
Divider()
|
||||||
self.channelSection(channel)
|
self.providerSection(provider)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
|
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
|
||||||
let isSelected = self.selectedChannel == channel
|
let isSelected = self.selectedProvider == provider
|
||||||
return Button {
|
return Button {
|
||||||
self.selectedChannel = channel
|
self.selectedProvider = provider
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(self.channelTint(channel))
|
.fill(self.providerTint(provider))
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(channel.title)
|
Text(provider.title)
|
||||||
Text(self.channelSummary(channel))
|
Text(self.providerSummary(provider))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
|
|||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func detailHeader(for channel: ConnectionChannel) -> some View {
|
private func detailHeader(for provider: ConnectionProvider) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
Label(channel.detailTitle, systemImage: channel.systemImage)
|
Label(provider.detailTitle, systemImage: provider.systemImage)
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
self.statusBadge(
|
self.statusBadge(
|
||||||
self.channelSummary(channel),
|
self.providerSummary(provider),
|
||||||
color: self.channelTint(channel))
|
color: self.providerTint(provider))
|
||||||
Spacer()
|
Spacer()
|
||||||
self.channelHeaderActions(channel)
|
self.providerHeaderActions(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Text("Last check \(self.channelLastCheckText(channel))")
|
Text("Last check \(self.providerLastCheckText(provider))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if self.channelHasError(channel) {
|
if self.providerHasError(provider) {
|
||||||
Text("Error")
|
Text("Error")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let details = self.channelDetails(channel) {
|
if let details = self.providerDetails(provider) {
|
||||||
Text(details)
|
Text(details)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ConnectionsSettings: View {
|
struct ConnectionsSettings: View {
|
||||||
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
|
enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
|
||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bindable var store: ConnectionsStore
|
@Bindable var store: ConnectionsStore
|
||||||
@State var selectedChannel: ConnectionChannel?
|
@State var selectedProvider: ConnectionProvider?
|
||||||
@State var showTelegramToken = false
|
@State var showTelegramToken = false
|
||||||
@State var showDiscordToken = false
|
@State var showDiscordToken = false
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,6 @@ import ClawdbotProtocol
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension ConnectionsStore {
|
extension ConnectionsStore {
|
||||||
var isTelegramTokenLocked: Bool {
|
|
||||||
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
|
||||||
.tokenSource == "env"
|
|
||||||
}
|
|
||||||
|
|
||||||
var isDiscordTokenLocked: Bool {
|
|
||||||
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
|
||||||
.tokenSource == "env"
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() async {
|
func loadConfig() async {
|
||||||
do {
|
do {
|
||||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
@@ -22,7 +12,6 @@ extension ConnectionsStore {
|
|||||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||||
: nil
|
: nil
|
||||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||||
self.configHash = snap.hash
|
|
||||||
self.configLoaded = true
|
self.configLoaded = true
|
||||||
|
|
||||||
self.applyUIConfig(snap)
|
self.applyUIConfig(snap)
|
||||||
@@ -45,23 +34,10 @@ extension ConnectionsStore {
|
|||||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
|
||||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
|
||||||
let entry = channels[key]?.dictionaryValue
|
|
||||||
{
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
return snap.config?[key]?.dictionaryValue
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||||
let groups = telegram?["groups"]?.dictionaryValue
|
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
|
||||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
|
||||||
?? telegram?["requireMention"]?.boolValue
|
|
||||||
?? true
|
|
||||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||||
@@ -70,7 +46,7 @@ extension ConnectionsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
let discord = snap.config?["discord"]?.dictionaryValue
|
||||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||||
|
|
||||||
@@ -146,7 +122,7 @@ extension ConnectionsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
let signal = snap.config?["signal"]?.dictionaryValue
|
||||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||||
@@ -163,7 +139,7 @@ extension ConnectionsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||||
@@ -174,15 +150,6 @@ extension ConnectionsStore {
|
|||||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
|
||||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
|
||||||
let entry = channels[key] as? [String: Any]
|
|
||||||
{
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveTelegramConfig() async {
|
func saveTelegramConfig() async {
|
||||||
guard !self.isSavingConfig else { return }
|
guard !self.isSavingConfig else { return }
|
||||||
self.isSavingConfig = true
|
self.isSavingConfig = true
|
||||||
@@ -191,24 +158,30 @@ extension ConnectionsStore {
|
|||||||
await self.loadConfig()
|
await self.loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
var telegram: [String: Any] = [:]
|
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
|
||||||
if !self.isTelegramTokenLocked {
|
let token = self.trimmed(self.telegramToken)
|
||||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
if token.isEmpty {
|
||||||
|
telegram.removeValue(forKey: "botToken")
|
||||||
|
} else {
|
||||||
|
telegram["botToken"] = token
|
||||||
}
|
}
|
||||||
telegram["requireMention"] = NSNull()
|
|
||||||
telegram["groups"] = [
|
|
||||||
"*": [
|
|
||||||
"requireMention": self.telegramRequireMention,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
|
||||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
|
||||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
|
||||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
|
||||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
|
||||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
|
||||||
|
|
||||||
await self.persistChannelPatch("telegram", payload: telegram)
|
telegram["requireMention"] = self.telegramRequireMention
|
||||||
|
|
||||||
|
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||||
|
if allow.isEmpty {
|
||||||
|
telegram.removeValue(forKey: "allowFrom")
|
||||||
|
} else {
|
||||||
|
telegram["allowFrom"] = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||||
|
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||||
|
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||||
|
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||||
|
|
||||||
|
self.setSection("telegram", payload: telegram)
|
||||||
|
await self.persistConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveDiscordConfig() async {
|
func saveDiscordConfig() async {
|
||||||
@@ -219,9 +192,9 @@ extension ConnectionsStore {
|
|||||||
await self.loadConfig()
|
await self.loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
let base = self.channelConfigRoot(for: "discord")
|
let discord = self.buildDiscordConfig()
|
||||||
let discord = self.buildDiscordPatch(base: base)
|
self.setSection("discord", payload: discord)
|
||||||
await self.persistChannelPatch("discord", payload: discord)
|
await self.persistConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSignalConfig() async {
|
func saveSignalConfig() async {
|
||||||
@@ -232,23 +205,42 @@ extension ConnectionsStore {
|
|||||||
await self.loadConfig()
|
await self.loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
var signal: [String: Any] = [:]
|
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||||
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
if self.signalEnabled {
|
||||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
signal.removeValue(forKey: "enabled")
|
||||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
} else {
|
||||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
signal["enabled"] = false
|
||||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
}
|
||||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
|
||||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
|
||||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
|
||||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
|
||||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
|
||||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
|
||||||
let allow = self.splitCsv(self.signalAllowFrom)
|
|
||||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
|
||||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
|
||||||
|
|
||||||
await self.persistChannelPatch("signal", payload: signal)
|
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
|
||||||
|
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||||
|
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||||
|
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||||
|
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||||
|
|
||||||
|
if self.signalAutoStart {
|
||||||
|
signal.removeValue(forKey: "autoStart")
|
||||||
|
} else {
|
||||||
|
signal["autoStart"] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||||
|
|
||||||
|
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
|
||||||
|
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
|
||||||
|
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
|
||||||
|
|
||||||
|
let allow = self.splitCsv(self.signalAllowFrom)
|
||||||
|
if allow.isEmpty {
|
||||||
|
signal.removeValue(forKey: "allowFrom")
|
||||||
|
} else {
|
||||||
|
signal["allowFrom"] = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||||
|
|
||||||
|
self.setSection("signal", payload: signal)
|
||||||
|
await self.persistConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveIMessageConfig() async {
|
func saveIMessageConfig() async {
|
||||||
@@ -259,168 +251,147 @@ extension ConnectionsStore {
|
|||||||
await self.loadConfig()
|
await self.loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
var imessage: [String: Any] = [:]
|
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
|
||||||
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
if self.imessageEnabled {
|
||||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
imessage.removeValue(forKey: "enabled")
|
||||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
} else {
|
||||||
|
imessage["enabled"] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||||
|
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||||
|
|
||||||
let service = self.trimmed(self.imessageService)
|
let service = self.trimmed(self.imessageService)
|
||||||
if service.isEmpty || service == "auto" {
|
if service.isEmpty || service == "auto" {
|
||||||
imessage["service"] = NSNull()
|
imessage.removeValue(forKey: "service")
|
||||||
} else {
|
} else {
|
||||||
imessage["service"] = service
|
imessage["service"] = service
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
|
||||||
|
|
||||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
if allow.isEmpty {
|
||||||
|
imessage.removeValue(forKey: "allowFrom")
|
||||||
|
} else {
|
||||||
|
imessage["allowFrom"] = allow
|
||||||
|
}
|
||||||
|
|
||||||
self.setPatchBool(
|
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
|
||||||
&imessage,
|
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||||
key: "includeAttachments",
|
|
||||||
value: self.imessageIncludeAttachments,
|
|
||||||
defaultValue: false)
|
|
||||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
|
||||||
|
|
||||||
await self.persistChannelPatch("imessage", payload: imessage)
|
self.setSection("imessage", payload: imessage)
|
||||||
|
await self.persistConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
private func buildDiscordConfig() -> [String: Any] {
|
||||||
var discord: [String: Any] = [:]
|
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
if self.discordEnabled {
|
||||||
if !self.isDiscordTokenLocked {
|
discord.removeValue(forKey: "enabled")
|
||||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
} else {
|
||||||
|
discord["enabled"] = false
|
||||||
}
|
}
|
||||||
|
self.setOptionalString(&discord, key: "token", value: self.discordToken)
|
||||||
|
|
||||||
if let dm = self.buildDiscordDmPatch() {
|
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
|
||||||
discord["dm"] = dm
|
discord["dm"] = dm
|
||||||
} else {
|
} else {
|
||||||
discord["dm"] = NSNull()
|
discord.removeValue(forKey: "dm")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||||
|
|
||||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
if replyToMode.isEmpty || replyToMode == "off" {
|
||||||
discord["replyToMode"] = NSNull()
|
discord.removeValue(forKey: "replyToMode")
|
||||||
} else {
|
} else if ["first", "all"].contains(replyToMode) {
|
||||||
discord["replyToMode"] = replyToMode
|
discord["replyToMode"] = replyToMode
|
||||||
|
} else {
|
||||||
|
discord.removeValue(forKey: "replyToMode")
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
if let guilds = self.buildDiscordGuildsConfig() {
|
||||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
|
||||||
discord["guilds"] = guilds
|
discord["guilds"] = guilds
|
||||||
} else {
|
} else {
|
||||||
discord["guilds"] = NSNull()
|
discord.removeValue(forKey: "guilds")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let actions = self.buildDiscordActionsPatch() {
|
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
|
||||||
discord["actions"] = actions
|
discord["actions"] = actions
|
||||||
} else {
|
} else {
|
||||||
discord["actions"] = NSNull()
|
discord.removeValue(forKey: "actions")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let slash = self.buildDiscordSlashPatch() {
|
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
|
||||||
discord["slashCommand"] = slash
|
discord["slashCommand"] = slash
|
||||||
} else {
|
} else {
|
||||||
discord["slashCommand"] = NSNull()
|
discord.removeValue(forKey: "slashCommand")
|
||||||
}
|
}
|
||||||
|
|
||||||
return discord
|
return discord
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
|
||||||
var dm: [String: Any] = [:]
|
var dm = base
|
||||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
if self.discordDmEnabled {
|
||||||
|
dm.removeValue(forKey: "enabled")
|
||||||
|
} else {
|
||||||
|
dm["enabled"] = false
|
||||||
|
}
|
||||||
let allow = self.splitCsv(self.discordAllowFrom)
|
let allow = self.splitCsv(self.discordAllowFrom)
|
||||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
if allow.isEmpty {
|
||||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
dm.removeValue(forKey: "allowFrom")
|
||||||
|
} else {
|
||||||
|
dm["allowFrom"] = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.discordGroupEnabled {
|
||||||
|
dm["groupEnabled"] = true
|
||||||
|
} else {
|
||||||
|
dm.removeValue(forKey: "groupEnabled")
|
||||||
|
}
|
||||||
|
|
||||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
if groupChannels.isEmpty {
|
||||||
|
dm.removeValue(forKey: "groupChannels")
|
||||||
|
} else {
|
||||||
|
dm["groupChannels"] = groupChannels
|
||||||
|
}
|
||||||
|
|
||||||
return dm.isEmpty ? nil : dm
|
return dm.isEmpty ? nil : dm
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
private func buildDiscordGuildsConfig() -> [String: Any]? {
|
||||||
if self.discordGuilds.isEmpty {
|
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||||
return NSNull()
|
|
||||||
}
|
|
||||||
var patch: [String: Any] = [:]
|
|
||||||
let baseKeys = Set(base.keys)
|
|
||||||
var formKeys = Set<String>()
|
|
||||||
for entry in self.discordGuilds {
|
|
||||||
let key = self.trimmed(entry.key)
|
let key = self.trimmed(entry.key)
|
||||||
guard !key.isEmpty else { continue }
|
guard !key.isEmpty else { return }
|
||||||
formKeys.insert(key)
|
var payload: [String: Any] = [:]
|
||||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
let slug = self.trimmed(entry.slug)
|
||||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
if !slug.isEmpty { payload["slug"] = slug }
|
||||||
|
if entry.requireMention { payload["requireMention"] = true }
|
||||||
|
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||||
|
payload["reactionNotifications"] = entry.reactionNotifications
|
||||||
|
}
|
||||||
|
let users = self.splitCsv(entry.users)
|
||||||
|
if !users.isEmpty { payload["users"] = users }
|
||||||
|
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||||
|
let channelKey = self.trimmed(channel.key)
|
||||||
|
guard !channelKey.isEmpty else { return }
|
||||||
|
var channelPayload: [String: Any] = [:]
|
||||||
|
if !channel.allow { channelPayload["allow"] = false }
|
||||||
|
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||||
|
channelsResult[channelKey] = channelPayload
|
||||||
|
}
|
||||||
|
if !channels.isEmpty { payload["channels"] = channels }
|
||||||
|
result[key] = payload
|
||||||
}
|
}
|
||||||
for key in baseKeys.subtracting(formKeys) {
|
return guilds.isEmpty ? nil : guilds
|
||||||
patch[key] = NSNull()
|
|
||||||
}
|
|
||||||
return patch.isEmpty ? NSNull() : patch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
|
||||||
var payload: [String: Any] = [:]
|
var actions = base
|
||||||
let slug = self.trimmed(entry.slug)
|
|
||||||
if slug.isEmpty {
|
|
||||||
payload["slug"] = NSNull()
|
|
||||||
} else {
|
|
||||||
payload["slug"] = slug
|
|
||||||
}
|
|
||||||
if entry.requireMention {
|
|
||||||
payload["requireMention"] = true
|
|
||||||
} else {
|
|
||||||
payload["requireMention"] = NSNull()
|
|
||||||
}
|
|
||||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
|
||||||
payload["reactionNotifications"] = entry.reactionNotifications
|
|
||||||
} else {
|
|
||||||
payload["reactionNotifications"] = NSNull()
|
|
||||||
}
|
|
||||||
let users = self.splitCsv(entry.users)
|
|
||||||
self.setPatchList(&payload, key: "users", values: users)
|
|
||||||
|
|
||||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
|
||||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
|
||||||
payload["channels"] = channels
|
|
||||||
} else {
|
|
||||||
payload["channels"] = NSNull()
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
|
||||||
if forms.isEmpty {
|
|
||||||
return NSNull()
|
|
||||||
}
|
|
||||||
var patch: [String: Any] = [:]
|
|
||||||
let baseKeys = Set(base.keys)
|
|
||||||
var formKeys = Set<String>()
|
|
||||||
for channel in forms {
|
|
||||||
let channelKey = self.trimmed(channel.key)
|
|
||||||
guard !channelKey.isEmpty else { continue }
|
|
||||||
formKeys.insert(channelKey)
|
|
||||||
var channelPayload: [String: Any] = [:]
|
|
||||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
|
||||||
self.setPatchBool(
|
|
||||||
&channelPayload,
|
|
||||||
key: "requireMention",
|
|
||||||
value: channel.requireMention,
|
|
||||||
defaultValue: false)
|
|
||||||
patch[channelKey] = channelPayload
|
|
||||||
}
|
|
||||||
for key in baseKeys.subtracting(formKeys) {
|
|
||||||
patch[key] = NSNull()
|
|
||||||
}
|
|
||||||
return patch.isEmpty ? NSNull() : patch
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
|
||||||
var actions: [String: Any] = [:]
|
|
||||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||||
@@ -439,44 +410,52 @@ extension ConnectionsStore {
|
|||||||
return actions.isEmpty ? nil : actions
|
return actions.isEmpty ? nil : actions
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
|
||||||
var slash: [String: Any] = [:]
|
var slash = base
|
||||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
if self.discordSlashEnabled {
|
||||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
slash["enabled"] = true
|
||||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
} else {
|
||||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
slash.removeValue(forKey: "enabled")
|
||||||
|
}
|
||||||
|
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
|
||||||
|
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||||
|
if self.discordSlashEphemeral {
|
||||||
|
slash.removeValue(forKey: "ephemeral")
|
||||||
|
} else {
|
||||||
|
slash["ephemeral"] = false
|
||||||
|
}
|
||||||
return slash.isEmpty ? nil : slash
|
return slash.isEmpty ? nil : slash
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
private func persistConfig() async {
|
||||||
do {
|
do {
|
||||||
guard let baseHash = self.configHash else {
|
|
||||||
self.configStatus = "Config hash missing; reload and retry."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let data = try JSONSerialization.data(
|
let data = try JSONSerialization.data(
|
||||||
withJSONObject: ["channels": [channelId: payload]],
|
withJSONObject: self.configRoot,
|
||||||
options: [.prettyPrinted, .sortedKeys])
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
self.configStatus = "Failed to encode config."
|
self.configStatus = "Failed to encode config."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let params: [String: AnyCodable] = [
|
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||||
"raw": AnyCodable(raw),
|
|
||||||
"baseHash": AnyCodable(baseHash),
|
|
||||||
]
|
|
||||||
_ = try await GatewayConnection.shared.requestRaw(
|
_ = try await GatewayConnection.shared.requestRaw(
|
||||||
method: .configPatch,
|
method: .configSet,
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 10000)
|
timeoutMs: 10000)
|
||||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||||
await self.loadConfig()
|
|
||||||
await self.refresh(probe: true)
|
await self.refresh(probe: true)
|
||||||
} catch {
|
} catch {
|
||||||
self.configStatus = error.localizedDescription
|
self.configStatus = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setSection(_ key: String, payload: [String: Any]) {
|
||||||
|
if payload.isEmpty {
|
||||||
|
self.configRoot.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
self.configRoot[key] = payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func stringList(from values: [AnyCodable]?) -> String {
|
private func stringList(from values: [AnyCodable]?) -> String {
|
||||||
guard let values else { return "" }
|
guard let values else { return "" }
|
||||||
let strings = values.compactMap { entry -> String? in
|
let strings = values.compactMap { entry -> String? in
|
||||||
@@ -513,29 +492,25 @@ extension ConnectionsStore {
|
|||||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
|
||||||
let trimmed = self.trimmed(value)
|
let trimmed = self.trimmed(value)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
target[key] = NSNull()
|
target.removeValue(forKey: key)
|
||||||
} else {
|
} else {
|
||||||
target[key] = trimmed
|
target[key] = trimmed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||||
let trimmed = self.trimmed(value)
|
let trimmed = self.trimmed(value)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
target[key] = NSNull()
|
target.removeValue(forKey: key)
|
||||||
return
|
} else if let number = Double(trimmed) {
|
||||||
}
|
|
||||||
if let number = Double(trimmed) {
|
|
||||||
target[key] = number
|
target[key] = number
|
||||||
} else {
|
|
||||||
target[key] = NSNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPatchInt(
|
private func setOptionalInt(
|
||||||
_ target: inout [String: Any],
|
_ target: inout [String: Any],
|
||||||
key: String,
|
key: String,
|
||||||
value: String,
|
value: String,
|
||||||
@@ -543,39 +518,26 @@ extension ConnectionsStore {
|
|||||||
{
|
{
|
||||||
let trimmed = self.trimmed(value)
|
let trimmed = self.trimmed(value)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
target[key] = NSNull()
|
target.removeValue(forKey: key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let number = Int(trimmed) else {
|
guard let number = Int(trimmed) else {
|
||||||
target[key] = NSNull()
|
target.removeValue(forKey: key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let isValid = allowZero ? number >= 0 : number > 0
|
let isValid = allowZero ? number >= 0 : number > 0
|
||||||
guard isValid else {
|
guard isValid else {
|
||||||
target[key] = NSNull()
|
target.removeValue(forKey: key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target[key] = number
|
target[key] = number
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPatchBool(
|
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
|
||||||
_ target: inout [String: Any],
|
if value {
|
||||||
key: String,
|
target[key] = true
|
||||||
value: Bool,
|
|
||||||
defaultValue: Bool)
|
|
||||||
{
|
|
||||||
if value == defaultValue {
|
|
||||||
target[key] = NSNull()
|
|
||||||
} else {
|
} else {
|
||||||
target[key] = value
|
target.removeValue(forKey: key)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
|
||||||
if values.isEmpty {
|
|
||||||
target[key] = NSNull()
|
|
||||||
} else {
|
|
||||||
target[key] = values
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +548,7 @@ extension ConnectionsStore {
|
|||||||
defaultValue: Bool)
|
defaultValue: Bool)
|
||||||
{
|
{
|
||||||
if value == defaultValue {
|
if value == defaultValue {
|
||||||
actions[key] = NSNull()
|
actions.removeValue(forKey: key)
|
||||||
} else {
|
} else {
|
||||||
actions[key] = value
|
actions[key] = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ extension ConnectionsStore {
|
|||||||
"probe": AnyCodable(probe),
|
"probe": AnyCodable(probe),
|
||||||
"timeoutMs": AnyCodable(8000),
|
"timeoutMs": AnyCodable(8000),
|
||||||
]
|
]
|
||||||
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .channelsStatus,
|
method: .providersStatus,
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 12000)
|
timeoutMs: 12000)
|
||||||
self.snapshot = snap
|
self.snapshot = snap
|
||||||
@@ -101,10 +101,10 @@ extension ConnectionsStore {
|
|||||||
defer { self.whatsappBusy = false }
|
defer { self.whatsappBusy = false }
|
||||||
do {
|
do {
|
||||||
let params: [String: AnyCodable] = [
|
let params: [String: AnyCodable] = [
|
||||||
"channel": AnyCodable("whatsapp"),
|
"provider": AnyCodable("whatsapp"),
|
||||||
]
|
]
|
||||||
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .channelsLogout,
|
method: .providersLogout,
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 15000)
|
timeoutMs: 15000)
|
||||||
self.whatsappLoginMessage = result.cleared
|
self.whatsappLoginMessage = result.cleared
|
||||||
@@ -123,10 +123,10 @@ extension ConnectionsStore {
|
|||||||
defer { self.telegramBusy = false }
|
defer { self.telegramBusy = false }
|
||||||
do {
|
do {
|
||||||
let params: [String: AnyCodable] = [
|
let params: [String: AnyCodable] = [
|
||||||
"channel": AnyCodable("telegram"),
|
"provider": AnyCodable("telegram"),
|
||||||
]
|
]
|
||||||
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .channelsLogout,
|
method: .providersLogout,
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 15000)
|
timeoutMs: 15000)
|
||||||
if result.envToken == true {
|
if result.envToken == true {
|
||||||
@@ -154,8 +154,8 @@ private struct WhatsAppLoginWaitResult: Codable {
|
|||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChannelLogoutResult: Codable {
|
private struct ProviderLogoutResult: Codable {
|
||||||
let channel: String?
|
let provider: String?
|
||||||
let accountId: String?
|
let accountId: String?
|
||||||
let cleared: Bool
|
let cleared: Bool
|
||||||
let envToken: Bool?
|
let envToken: Bool?
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import ClawdbotProtocol
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
struct ChannelsStatusSnapshot: Codable {
|
struct ProvidersStatusSnapshot: Codable {
|
||||||
struct WhatsAppSelf: Codable {
|
struct WhatsAppSelf: Codable {
|
||||||
let e164: String?
|
let e164: String?
|
||||||
let jid: String?
|
let jid: String?
|
||||||
@@ -121,7 +121,7 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
let lastProbeAt: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChannelAccountSnapshot: Codable {
|
struct ProviderAccountSnapshot: Codable {
|
||||||
let accountId: String
|
let accountId: String
|
||||||
let name: String?
|
let name: String?
|
||||||
let enabled: Bool?
|
let enabled: Bool?
|
||||||
@@ -154,14 +154,14 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let channelOrder: [String]
|
let providerOrder: [String]
|
||||||
let channelLabels: [String: String]
|
let providerLabels: [String: String]
|
||||||
let channels: [String: AnyCodable]
|
let providers: [String: AnyCodable]
|
||||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
let providerAccounts: [String: [ProviderAccountSnapshot]]
|
||||||
let channelDefaultAccountId: [String: String]
|
let providerDefaultAccountId: [String: String]
|
||||||
|
|
||||||
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||||
guard let value = self.channels[id] else { return nil }
|
guard let value = self.providers[id] else { return nil }
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(value)
|
let data = try JSONEncoder().encode(value)
|
||||||
return try JSONDecoder().decode(type, from: data)
|
return try JSONDecoder().decode(type, from: data)
|
||||||
@@ -180,7 +180,6 @@ struct ConfigSnapshot: Codable {
|
|||||||
let path: String?
|
let path: String?
|
||||||
let exists: Bool?
|
let exists: Bool?
|
||||||
let raw: String?
|
let raw: String?
|
||||||
let hash: String?
|
|
||||||
let parsed: AnyCodable?
|
let parsed: AnyCodable?
|
||||||
let valid: Bool?
|
let valid: Bool?
|
||||||
let config: [String: AnyCodable]?
|
let config: [String: AnyCodable]?
|
||||||
@@ -231,7 +230,7 @@ struct DiscordGuildForm: Identifiable {
|
|||||||
final class ConnectionsStore {
|
final class ConnectionsStore {
|
||||||
static let shared = ConnectionsStore()
|
static let shared = ConnectionsStore()
|
||||||
|
|
||||||
var snapshot: ChannelsStatusSnapshot?
|
var snapshot: ProvidersStatusSnapshot?
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
var lastSuccess: Date?
|
var lastSuccess: Date?
|
||||||
var isRefreshing = false
|
var isRefreshing = false
|
||||||
@@ -308,7 +307,6 @@ final class ConnectionsStore {
|
|||||||
var pollTask: Task<Void, Never>?
|
var pollTask: Task<Void, Never>?
|
||||||
var configRoot: [String: Any] = [:]
|
var configRoot: [String: Any] = [:]
|
||||||
var configLoaded = false
|
var configLoaded = false
|
||||||
var configHash: String?
|
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
|||||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||||
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
|
||||||
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
|
||||||
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
|
||||||
let locationModeKey = "clawdbot.locationMode"
|
let locationModeKey = "clawdbot.locationMode"
|
||||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ extension CronJobEditor {
|
|||||||
case let .systemEvent(text):
|
case let .systemEvent(text):
|
||||||
self.payloadKind = .systemEvent
|
self.payloadKind = .systemEvent
|
||||||
self.systemEventText = text
|
self.systemEventText = text
|
||||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = message
|
self.agentMessage = message
|
||||||
self.thinking = thinking ?? ""
|
self.thinking = thinking ?? ""
|
||||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
self.deliver = deliver ?? false
|
self.deliver = deliver ?? false
|
||||||
self.channel = GatewayAgentChannel(raw: channel)
|
self.provider = GatewayAgentProvider(raw: provider)
|
||||||
self.to = to ?? ""
|
self.to = to ?? ""
|
||||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
}
|
}
|
||||||
@@ -188,15 +188,9 @@ extension CronJobEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDeleteAfterRun(
|
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||||
to root: inout [String: Any],
|
if self.scheduleKind == .at {
|
||||||
scheduleKind: ScheduleKind? = nil,
|
root["deleteAfterRun"] = self.deleteAfterRun
|
||||||
deleteAfterRun: Bool? = nil)
|
|
||||||
{
|
|
||||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
|
||||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
|
||||||
if resolvedSchedule == .at {
|
|
||||||
root["deleteAfterRun"] = resolvedDelete
|
|
||||||
} else if self.job?.deleteAfterRun != nil {
|
} else if self.job?.deleteAfterRun != nil {
|
||||||
root["deleteAfterRun"] = false
|
root["deleteAfterRun"] = false
|
||||||
}
|
}
|
||||||
@@ -210,7 +204,7 @@ extension CronJobEditor {
|
|||||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
payload["deliver"] = self.deliver
|
payload["deliver"] = self.deliver
|
||||||
if self.deliver {
|
if self.deliver {
|
||||||
payload["channel"] = self.channel.rawValue
|
payload["provider"] = self.provider.rawValue
|
||||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !to.isEmpty { payload["to"] = to }
|
if !to.isEmpty { payload["to"] = to }
|
||||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extension CronJobEditor {
|
|||||||
self.payloadKind = .agentTurn
|
self.payloadKind = .agentTurn
|
||||||
self.agentMessage = "Run diagnostic"
|
self.agentMessage = "Run diagnostic"
|
||||||
self.deliver = true
|
self.deliver = true
|
||||||
self.channel = .last
|
self.provider = .last
|
||||||
self.to = "+15551230000"
|
self.to = "+15551230000"
|
||||||
self.thinking = "low"
|
self.thinking = "low"
|
||||||
self.timeoutSeconds = "90"
|
self.timeoutSeconds = "90"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct CronJobEditor: View {
|
|||||||
static let scheduleKindNote =
|
static let scheduleKindNote =
|
||||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
static let isolatedPayloadNote =
|
static let isolatedPayloadNote =
|
||||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
|
||||||
+ "and a short summary is posted back to your main chat."
|
+ "and a short summary is posted back to your main chat."
|
||||||
static let mainPayloadNote =
|
static let mainPayloadNote =
|
||||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||||
@@ -45,7 +45,7 @@ struct CronJobEditor: View {
|
|||||||
@State var systemEventText: String = ""
|
@State var systemEventText: String = ""
|
||||||
@State var agentMessage: String = ""
|
@State var agentMessage: String = ""
|
||||||
@State var deliver: Bool = false
|
@State var deliver: Bool = false
|
||||||
@State var channel: GatewayAgentChannel = .last
|
@State var provider: GatewayAgentProvider = .last
|
||||||
@State var to: String = ""
|
@State var to: String = ""
|
||||||
@State var thinking: String = ""
|
@State var thinking: String = ""
|
||||||
@State var timeoutSeconds: String = ""
|
@State var timeoutSeconds: String = ""
|
||||||
@@ -323,7 +323,7 @@ struct CronJobEditor: View {
|
|||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Deliver")
|
self.gridLabel("Deliver")
|
||||||
Toggle("Deliver result to a channel", isOn: self.$deliver)
|
Toggle("Deliver result to a provider", isOn: self.$deliver)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,15 +331,15 @@ struct CronJobEditor: View {
|
|||||||
if self.deliver {
|
if self.deliver {
|
||||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Channel")
|
self.gridLabel("Provider")
|
||||||
Picker("", selection: self.$channel) {
|
Picker("", selection: self.$provider) {
|
||||||
Text("last").tag(GatewayAgentChannel.last)
|
Text("last").tag(GatewayAgentProvider.last)
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
|
||||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
Text("telegram").tag(GatewayAgentProvider.telegram)
|
||||||
Text("discord").tag(GatewayAgentChannel.discord)
|
Text("discord").tag(GatewayAgentProvider.discord)
|
||||||
Text("slack").tag(GatewayAgentChannel.slack)
|
Text("slack").tag(GatewayAgentProvider.slack)
|
||||||
Text("signal").tag(GatewayAgentChannel.signal)
|
Text("signal").tag(GatewayAgentProvider.signal)
|
||||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
Text("imessage").tag(GatewayAgentProvider.imessage)
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
|
|||||||
thinking: String?,
|
thinking: String?,
|
||||||
timeoutSeconds: Int?,
|
timeoutSeconds: Int?,
|
||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
channel: String?,
|
provider: String?,
|
||||||
to: String?,
|
to: String?,
|
||||||
bestEffortDeliver: Bool?)
|
bestEffortDeliver: Bool?)
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
|
||||||
}
|
}
|
||||||
|
|
||||||
var kind: String {
|
var kind: String {
|
||||||
@@ -101,8 +101,7 @@ enum CronPayload: Codable, Equatable {
|
|||||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||||
channel: container.decodeIfPresent(String.self, forKey: .channel)
|
provider: container.decodeIfPresent(String.self, forKey: .provider),
|
||||||
?? container.decodeIfPresent(String.self, forKey: .provider),
|
|
||||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||||
default:
|
default:
|
||||||
@@ -119,12 +118,12 @@ enum CronPayload: Codable, Equatable {
|
|||||||
switch self {
|
switch self {
|
||||||
case let .systemEvent(text):
|
case let .systemEvent(text):
|
||||||
try container.encode(text, forKey: .text)
|
try container.encode(text, forKey: .text)
|
||||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||||
try container.encode(message, forKey: .message)
|
try container.encode(message, forKey: .message)
|
||||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||||
try container.encodeIfPresent(channel, forKey: .channel)
|
try container.encodeIfPresent(provider, forKey: .provider)
|
||||||
try container.encodeIfPresent(to, forKey: .to)
|
try container.encodeIfPresent(to, forKey: .to)
|
||||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct CronSettings_Previews: PreviewProvider {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "last",
|
provider: "last",
|
||||||
to: nil,
|
to: nil,
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
@@ -76,7 +76,7 @@ extension CronSettings {
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
deliver: true,
|
deliver: true,
|
||||||
channel: "whatsapp",
|
provider: "whatsapp",
|
||||||
to: "+15551234567",
|
to: "+15551234567",
|
||||||
bestEffortDeliver: true),
|
bestEffortDeliver: true),
|
||||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let provider = GatewayAgentProvider(raw: link.channel)
|
||||||
let explicitSessionKey = link.sessionKey?
|
let explicitSessionKey = link.sessionKey?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.nonEmpty
|
.nonEmpty
|
||||||
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
|
|||||||
message: messagePreview,
|
message: messagePreview,
|
||||||
sessionKey: resolvedSessionKey,
|
sessionKey: resolvedSessionKey,
|
||||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
deliver: channel.shouldDeliver(link.deliver),
|
deliver: provider.shouldDeliver(link.deliver),
|
||||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
channel: channel,
|
provider: provider,
|
||||||
timeoutSeconds: link.timeoutSeconds,
|
timeoutSeconds: link.timeoutSeconds,
|
||||||
idempotencyKey: UUID().uuidString)
|
idempotencyKey: UUID().uuidString)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
||||||
|
case last
|
||||||
|
case webchat
|
||||||
|
case whatsapp
|
||||||
|
case telegram
|
||||||
|
|
||||||
|
init(raw: String?) {
|
||||||
|
let trimmed = raw?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased() ?? ""
|
||||||
|
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .webchat:
|
||||||
|
false
|
||||||
|
case .last:
|
||||||
|
isLast
|
||||||
|
case .whatsapp, .telegram:
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import OSLog
|
|||||||
|
|
||||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||||
|
|
||||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||||
case last
|
case last
|
||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
@@ -18,7 +18,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
|
|
||||||
init(raw: String?) {
|
init(raw: String?) {
|
||||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
self = GatewayAgentProvider(rawValue: normalized) ?? .last
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDeliverable: Bool { self != .webchat }
|
var isDeliverable: Bool { self != .webchat }
|
||||||
@@ -32,7 +32,7 @@ struct GatewayAgentInvocation: Sendable {
|
|||||||
var thinking: String?
|
var thinking: String?
|
||||||
var deliver: Bool = false
|
var deliver: Bool = false
|
||||||
var to: String?
|
var to: String?
|
||||||
var channel: GatewayAgentChannel = .last
|
var provider: GatewayAgentProvider = .last
|
||||||
var timeoutSeconds: Int?
|
var timeoutSeconds: Int?
|
||||||
var idempotencyKey: String = UUID().uuidString
|
var idempotencyKey: String = UUID().uuidString
|
||||||
}
|
}
|
||||||
@@ -52,10 +52,9 @@ actor GatewayConnection {
|
|||||||
case setHeartbeats = "set-heartbeats"
|
case setHeartbeats = "set-heartbeats"
|
||||||
case systemEvent = "system-event"
|
case systemEvent = "system-event"
|
||||||
case health
|
case health
|
||||||
case channelsStatus = "channels.status"
|
case providersStatus = "providers.status"
|
||||||
case configGet = "config.get"
|
case configGet = "config.get"
|
||||||
case configSet = "config.set"
|
case configSet = "config.set"
|
||||||
case configPatch = "config.patch"
|
|
||||||
case wizardStart = "wizard.start"
|
case wizardStart = "wizard.start"
|
||||||
case wizardNext = "wizard.next"
|
case wizardNext = "wizard.next"
|
||||||
case wizardCancel = "wizard.cancel"
|
case wizardCancel = "wizard.cancel"
|
||||||
@@ -63,7 +62,7 @@ actor GatewayConnection {
|
|||||||
case talkMode = "talk.mode"
|
case talkMode = "talk.mode"
|
||||||
case webLoginStart = "web.login.start"
|
case webLoginStart = "web.login.start"
|
||||||
case webLoginWait = "web.login.wait"
|
case webLoginWait = "web.login.wait"
|
||||||
case channelsLogout = "channels.logout"
|
case providersLogout = "providers.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
@@ -233,18 +232,7 @@ actor GatewayConnection {
|
|||||||
|
|
||||||
func canvasHostUrl() async -> String? {
|
func canvasHostUrl() async -> String? {
|
||||||
guard let snapshot = self.lastSnapshot else { return nil }
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
|
||||||
(defaults?[key]?.stringValue ?? "")
|
|
||||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cachedMainSessionKey() -> String? {
|
|
||||||
guard let snapshot = self.lastSnapshot else { return nil }
|
|
||||||
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,35 +267,12 @@ actor GatewayConnection {
|
|||||||
private func broadcast(_ push: GatewayPush) {
|
private func broadcast(_ push: GatewayPush) {
|
||||||
if case let .snapshot(snapshot) = push {
|
if case let .snapshot(snapshot) = push {
|
||||||
self.lastSnapshot = snapshot
|
self.lastSnapshot = snapshot
|
||||||
if let mainSessionKey = self.cachedMainSessionKey() {
|
|
||||||
Task { @MainActor in
|
|
||||||
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (_, continuation) in self.subscribers {
|
for (_, continuation) in self.subscribers {
|
||||||
continuation.yield(push)
|
continuation.yield(push)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canonicalizeSessionKey(_ raw: String) -> String {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return trimmed }
|
|
||||||
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
|
||||||
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
|
|
||||||
guard !mainSessionKey.isEmpty else { return trimmed }
|
|
||||||
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
|
|
||||||
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
|
|
||||||
let isMainAlias =
|
|
||||||
trimmed == "main" ||
|
|
||||||
(!mainKey.isEmpty && trimmed == mainKey) ||
|
|
||||||
trimmed == mainSessionKey ||
|
|
||||||
(!defaultAgentId.isEmpty &&
|
|
||||||
(trimmed == "agent:\(defaultAgentId):main" ||
|
|
||||||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
|
|
||||||
return isMainAlias ? mainSessionKey : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configure(url: URL, token: String?, password: String?) async {
|
private func configure(url: URL, token: String?, password: String?) async {
|
||||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||||
self.configuredPassword == password
|
self.configuredPassword == password
|
||||||
@@ -366,9 +331,6 @@ extension GatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||||
if let cached = self.cachedMainSessionKey() {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||||
return try Self.mainSessionKey(fromConfigGetData: data)
|
return try Self.mainSessionKey(fromConfigGetData: data)
|
||||||
@@ -399,15 +361,14 @@ extension GatewayConnection {
|
|||||||
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
||||||
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return (false, "message empty") }
|
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||||
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
|
|
||||||
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: AnyCodable] = [
|
||||||
"message": AnyCodable(trimmed),
|
"message": AnyCodable(trimmed),
|
||||||
"sessionKey": AnyCodable(sessionKey),
|
"sessionKey": AnyCodable(invocation.sessionKey),
|
||||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||||
"deliver": AnyCodable(invocation.deliver),
|
"deliver": AnyCodable(invocation.deliver),
|
||||||
"to": AnyCodable(invocation.to ?? ""),
|
"to": AnyCodable(invocation.to ?? ""),
|
||||||
"channel": AnyCodable(invocation.channel.rawValue),
|
"provider": AnyCodable(invocation.provider.rawValue),
|
||||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||||
]
|
]
|
||||||
if let timeout = invocation.timeoutSeconds {
|
if let timeout = invocation.timeoutSeconds {
|
||||||
@@ -428,7 +389,7 @@ extension GatewayConnection {
|
|||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
deliver: Bool,
|
deliver: Bool,
|
||||||
to: String?,
|
to: String?,
|
||||||
channel: GatewayAgentChannel = .last,
|
provider: GatewayAgentProvider = .last,
|
||||||
timeoutSeconds: Int? = nil,
|
timeoutSeconds: Int? = nil,
|
||||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||||
{
|
{
|
||||||
@@ -438,7 +399,7 @@ extension GatewayConnection {
|
|||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel,
|
provider: provider,
|
||||||
timeoutSeconds: timeoutSeconds,
|
timeoutSeconds: timeoutSeconds,
|
||||||
idempotencyKey: idempotencyKey))
|
idempotencyKey: idempotencyKey))
|
||||||
}
|
}
|
||||||
@@ -507,8 +468,7 @@ extension GatewayConnection {
|
|||||||
limit: Int? = nil,
|
limit: Int? = nil,
|
||||||
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
|
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
|
||||||
{
|
{
|
||||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
|
||||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
|
|
||||||
if let limit { params["limit"] = AnyCodable(limit) }
|
if let limit { params["limit"] = AnyCodable(limit) }
|
||||||
let timeout = timeoutMs.map { Double($0) }
|
let timeout = timeoutMs.map { Double($0) }
|
||||||
return try await self.requestDecoded(
|
return try await self.requestDecoded(
|
||||||
@@ -525,9 +485,8 @@ extension GatewayConnection {
|
|||||||
attachments: [ClawdbotChatAttachmentPayload],
|
attachments: [ClawdbotChatAttachmentPayload],
|
||||||
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
|
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
|
||||||
{
|
{
|
||||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: AnyCodable] = [
|
||||||
"sessionKey": AnyCodable(resolvedKey),
|
"sessionKey": AnyCodable(sessionKey),
|
||||||
"message": AnyCodable(message),
|
"message": AnyCodable(message),
|
||||||
"thinking": AnyCodable(thinking),
|
"thinking": AnyCodable(thinking),
|
||||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||||
@@ -553,11 +512,10 @@ extension GatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
|
||||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||||
let res: AbortResponse = try await self.requestDecoded(
|
let res: AbortResponse = try await self.requestDecoded(
|
||||||
method: .chatAbort,
|
method: .chatAbort,
|
||||||
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
|
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
|
||||||
return res.aborted ?? false
|
return res.aborted ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,14 @@ actor GatewayEndpointStore {
|
|||||||
return GatewayEndpointStore.resolveGatewayToken(
|
return GatewayEndpointStore.resolveGatewayToken(
|
||||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment)
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
return GatewayEndpointStore.resolveGatewayPassword(
|
return GatewayEndpointStore.resolveGatewayPassword(
|
||||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment)
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
|
||||||
},
|
},
|
||||||
localPort: { GatewayEnvironment.gatewayPort() },
|
localPort: { GatewayEnvironment.gatewayPort() },
|
||||||
localHost: {
|
localHost: {
|
||||||
@@ -62,8 +60,7 @@ actor GatewayEndpointStore {
|
|||||||
private static func resolveGatewayPassword(
|
private static func resolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String],
|
env: [String: String]) -> String?
|
||||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
|
||||||
{
|
{
|
||||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -91,19 +88,13 @@ actor GatewayEndpointStore {
|
|||||||
return pw
|
return pw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!password.isEmpty
|
|
||||||
{
|
|
||||||
return password
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveGatewayToken(
|
private static func resolveGatewayToken(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String],
|
env: [String: String]) -> String?
|
||||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
|
||||||
{
|
{
|
||||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -131,11 +122,6 @@ actor GatewayEndpointStore {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!token.isEmpty
|
|
||||||
{
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,61 +388,14 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension GatewayEndpointStore {
|
|
||||||
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
|
|
||||||
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
|
|
||||||
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
switch components.scheme?.lowercased() {
|
|
||||||
case "ws":
|
|
||||||
components.scheme = "http"
|
|
||||||
case "wss":
|
|
||||||
components.scheme = "https"
|
|
||||||
default:
|
|
||||||
components.scheme = "http"
|
|
||||||
}
|
|
||||||
components.path = "/"
|
|
||||||
var queryItems: [URLQueryItem] = []
|
|
||||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!token.isEmpty
|
|
||||||
{
|
|
||||||
queryItems.append(URLQueryItem(name: "token", value: token))
|
|
||||||
}
|
|
||||||
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!password.isEmpty
|
|
||||||
{
|
|
||||||
queryItems.append(URLQueryItem(name: "password", value: password))
|
|
||||||
}
|
|
||||||
components.queryItems = queryItems.isEmpty ? nil : queryItems
|
|
||||||
guard let url = components.url else {
|
|
||||||
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension GatewayEndpointStore {
|
extension GatewayEndpointStore {
|
||||||
static func _testResolveGatewayPassword(
|
static func _testResolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String],
|
env: [String: String]) -> String?
|
||||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
|
||||||
{
|
{
|
||||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||||
}
|
|
||||||
|
|
||||||
static func _testResolveGatewayToken(
|
|
||||||
isRemote: Bool,
|
|
||||||
root: [String: Any],
|
|
||||||
env: [String: String],
|
|
||||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
|
||||||
{
|
|
||||||
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _testResolveGatewayBindMode(
|
static func _testResolveGatewayBindMode(
|
||||||
|
|||||||
@@ -2,16 +2,77 @@ import Foundation
|
|||||||
|
|
||||||
enum GatewayLaunchAgentManager {
|
enum GatewayLaunchAgentManager {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
|
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
|
private enum GatewayProgramArgumentsError: LocalizedError {
|
||||||
|
case message(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .message(message):
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var legacyPlistURL: URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func gatewayProgramArguments(
|
||||||
|
port: Int,
|
||||||
|
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||||
|
{
|
||||||
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
|
#if DEBUG
|
||||||
|
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||||
|
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
|
}
|
||||||
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||||
|
switch CommandResolver.runtimeResolution() {
|
||||||
|
case let .success(runtime):
|
||||||
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: "gateway-daemon",
|
||||||
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
|
case .failure:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
let searchPaths = CommandResolver.preferredPaths()
|
||||||
|
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||||
|
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||||
|
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||||
|
{
|
||||||
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: "gateway-daemon",
|
||||||
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
||||||
|
}
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||||
return loaded
|
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
return result.status == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
@@ -20,44 +81,251 @@ enum GatewayLaunchAgentManager {
|
|||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
self.logger.info("launchd enable requested via CLI port=\(port)")
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||||
return await self.runDaemonCommand([
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
"install",
|
|
||||||
"--force",
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
"--port",
|
let desiredToken = self.preferredGatewayToken()
|
||||||
"\(port)",
|
let desiredPassword = self.preferredGatewayPassword()
|
||||||
"--runtime",
|
let desiredConfig = DesiredConfig(
|
||||||
"node",
|
port: port,
|
||||||
])
|
bind: desiredBind,
|
||||||
|
token: desiredToken,
|
||||||
|
password: desiredPassword)
|
||||||
|
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||||
|
guard case let .success(programArguments) = programArgumentsResult else {
|
||||||
|
if case let .failure(error) = programArgumentsResult {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
self.logger.error("launchd enable failed: \(message)")
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "Failed to resolve gateway command."
|
||||||
|
}
|
||||||
|
|
||||||
|
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||||
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
|
let loaded = await self.isLoaded()
|
||||||
|
if loaded {
|
||||||
|
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||||
|
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||||
|
await self.ensureEnabled()
|
||||||
|
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
|
self.writePlist(programArguments: programArguments)
|
||||||
|
|
||||||
|
await self.ensureEnabled()
|
||||||
|
if loaded {
|
||||||
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
}
|
||||||
|
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
|
if bootstrap.status != 0 {
|
||||||
|
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||||
|
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? "Failed to bootstrap gateway launchd job"
|
||||||
|
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
await self.ensureEnabled()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd disable requested via CLI")
|
self.logger.info("launchd disable requested")
|
||||||
return await self.runDaemonCommand(["uninstall"])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
await self.ensureDisabled()
|
||||||
|
try? FileManager.default.removeItem(at: self.plistURL)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
static func kickstart() async {
|
||||||
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
private static func writePlist(programArguments: [String]) {
|
||||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let token = self.preferredGatewayToken()
|
||||||
|
let password = self.preferredGatewayPassword()
|
||||||
|
var envEntries = """
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>\(preferredPath)</string>
|
||||||
|
"""
|
||||||
|
if let token {
|
||||||
|
let escapedToken = self.escapePlistValue(token)
|
||||||
|
envEntries += """
|
||||||
|
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||||
|
<string>\(escapedToken)</string>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
if let password {
|
||||||
|
let escapedPassword = self.escapePlistValue(password)
|
||||||
|
envEntries += """
|
||||||
|
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||||
|
<string>\(escapedPassword)</string>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
let argsXml = programArguments
|
||||||
|
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||||
|
.joined(separator: "\n ")
|
||||||
|
let plist = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>\(gatewayLaunchdLabel)</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
\(argsXml)
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
\(envEntries)
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
do {
|
||||||
|
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func launchdGatewayLogPath() -> String {
|
private static func preferredGatewayBind() -> String? {
|
||||||
let snapshot = self.launchdConfigSnapshot()
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
return nil
|
||||||
!stdout.isEmpty
|
|
||||||
{
|
|
||||||
return stdout
|
|
||||||
}
|
}
|
||||||
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||||
!stderr.isEmpty
|
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let bind = gateway["bind"] as? String
|
||||||
{
|
{
|
||||||
return stderr
|
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayToken() -> String? {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let token = auth["token"] as? String
|
||||||
|
{
|
||||||
|
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayPassword() -> String? {
|
||||||
|
// First check environment variable
|
||||||
|
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
// Then check config file (gateway.auth.password)
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let password = auth["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func escapePlistValue(_ raw: String) -> String {
|
||||||
|
raw
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DesiredConfig: Equatable {
|
||||||
|
let port: Int
|
||||||
|
let bind: String
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstalledConfig: Equatable {
|
||||||
|
let port: Int?
|
||||||
|
let bind: String?
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
|
||||||
|
func matches(_ desired: DesiredConfig) -> Bool {
|
||||||
|
guard self.port == desired.port else { return false }
|
||||||
|
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||||
|
guard self.token == desired.token else { return false }
|
||||||
|
guard self.password == desired.password else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readPlistConfig() -> InstalledConfig? {
|
||||||
|
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||||
|
return InstalledConfig(
|
||||||
|
port: snapshot.port,
|
||||||
|
bind: snapshot.bind,
|
||||||
|
token: snapshot.token,
|
||||||
|
password: snapshot.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureEnabled() async {
|
||||||
|
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd enable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd enable failed: \(msg)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureDisabled() async {
|
||||||
|
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd disable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd disable failed: \(msg)")
|
||||||
}
|
}
|
||||||
return LogLocator.launchdGatewayLogPath
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,102 +335,20 @@ extension GatewayLaunchAgentManager {
|
|||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager.default.fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
#if DEBUG
|
||||||
let result = await self.runDaemonCommandResult(
|
extension GatewayLaunchAgentManager {
|
||||||
["status", "--json", "--no-probe"],
|
static func _testPreferredGatewayBind() -> String? {
|
||||||
timeout: 15,
|
self.preferredGatewayBind()
|
||||||
quiet: true)
|
|
||||||
guard result.success, let payload = result.payload else { return nil }
|
|
||||||
guard
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
||||||
let service = json["service"] as? [String: Any],
|
|
||||||
let loaded = service["loaded"] as? Bool
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return loaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CommandResult {
|
static func _testPreferredGatewayToken() -> String? {
|
||||||
let success: Bool
|
self.preferredGatewayToken()
|
||||||
let payload: Data?
|
|
||||||
let message: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ParsedDaemonJson {
|
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||||
let text: String
|
self.escapePlistValue(raw)
|
||||||
let object: [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func runDaemonCommand(
|
|
||||||
_ args: [String],
|
|
||||||
timeout: Double = 15,
|
|
||||||
quiet: Bool = false) async -> String?
|
|
||||||
{
|
|
||||||
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
|
||||||
if result.success { return nil }
|
|
||||||
return result.message ?? "Gateway daemon command failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func runDaemonCommandResult(
|
|
||||||
_ args: [String],
|
|
||||||
timeout: Double,
|
|
||||||
quiet: Bool) async -> CommandResult
|
|
||||||
{
|
|
||||||
let command = CommandResolver.clawdbotCommand(
|
|
||||||
subcommand: "daemon",
|
|
||||||
extraArgs: self.withJsonFlag(args))
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
|
||||||
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
|
||||||
let ok = parsed?.object["ok"] as? Bool
|
|
||||||
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
|
||||||
let payload = parsed?.text.data(using: .utf8)
|
|
||||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
|
||||||
let success = ok ?? response.success
|
|
||||||
if success {
|
|
||||||
return CommandResult(success: true, payload: payload, message: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if quiet {
|
|
||||||
return CommandResult(success: false, payload: payload, message: message)
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
|
||||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
|
||||||
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
|
||||||
?? "Gateway daemon command failed (\(exit))"
|
|
||||||
self.logger.error("\(fullMessage, privacy: .public)")
|
|
||||||
return CommandResult(success: false, payload: payload, message: detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
|
||||||
if args.contains("--json") { return args }
|
|
||||||
return args + ["--json"]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let start = trimmed.firstIndex(of: "{"),
|
|
||||||
let end = trimmed.lastIndex(of: "}")
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let jsonText = String(trimmed[start...end])
|
|
||||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
|
||||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
||||||
return ParsedDaemonJson(text: jsonText, object: object)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func summarize(_ text: String) -> String? {
|
|
||||||
let lines = text
|
|
||||||
.split(whereSeparator: \.isNewline)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard let last = lines.last else { return nil }
|
|
||||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -87,14 +87,6 @@ final class GatewayProcessManager {
|
|||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
|
||||||
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
|
||||||
switch self.status {
|
|
||||||
case .starting, .running, .attachedExisting:
|
|
||||||
return
|
|
||||||
case .stopped, .failed:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
self.status = .starting
|
self.status = .starting
|
||||||
self.logger.debug("gateway start requested")
|
self.logger.debug("gateway start requested")
|
||||||
|
|
||||||
@@ -148,7 +140,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func refreshLog() {
|
func refreshLog() {
|
||||||
guard self.logRefreshTask == nil else { return }
|
guard self.logRefreshTask == nil else { return }
|
||||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
let path = LogLocator.launchdGatewayLogPath
|
||||||
let limit = self.logLimit
|
let limit = self.logLimit
|
||||||
self.logRefreshTask = Task { [weak self] in
|
self.logRefreshTask = Task { [weak self] in
|
||||||
let log = await Task.detached(priority: .utility) {
|
let log = await Task.detached(priority: .utility) {
|
||||||
@@ -219,19 +211,19 @@ final class GatewayProcessManager {
|
|||||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||||
let instanceText = instance ?? "pid unknown"
|
let instanceText = instance ?? "pid unknown"
|
||||||
if let snap {
|
if let snap {
|
||||||
let linkId = snap.channelOrder?.first(where: {
|
let linkId = snap.providerOrder?.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
}) ?? snap.channels.keys.first(where: {
|
}) ?? snap.providers.keys.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||||
let label =
|
let label =
|
||||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||||
linkId?.capitalized ??
|
linkId?.capitalized ??
|
||||||
"channel"
|
"provider"
|
||||||
let linkText = linked ? "linked" : "not linked"
|
let linkText = linked ? "linked" : "not linked"
|
||||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||||
}
|
}
|
||||||
@@ -362,7 +354,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,28 +83,6 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Node Run Commands")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$state.systemRunPolicy) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Text("""
|
|
||||||
Controls remote command execution on this Mac when it is paired as a node. \
|
|
||||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
|
||||||
"Never" disables `system.run`.
|
|
||||||
""")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@@ -114,8 +92,7 @@ struct GeneralSettings: View {
|
|||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.pickerStyle(.segmented)
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
.disabled(self.locationMode == .off)
|
.disabled(self.locationMode == .off)
|
||||||
@@ -519,18 +496,18 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let snap = snapshot {
|
if let snap = snapshot {
|
||||||
let linkId = snap.channelOrder?.first(where: {
|
let linkId = snap.providerOrder?.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
}) ?? snap.channels.keys.first(where: {
|
}) ?? snap.providers.keys.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
let linkLabel =
|
let linkLabel =
|
||||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||||
linkId?.capitalized ??
|
linkId?.capitalized ??
|
||||||
"Link channel"
|
"Link provider"
|
||||||
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
|
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
|
||||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HealthSnapshot: Codable, Sendable {
|
struct HealthSnapshot: Codable, Sendable {
|
||||||
struct ChannelSummary: Codable, Sendable {
|
struct ProviderSummary: Codable, Sendable {
|
||||||
struct Probe: Codable, Sendable {
|
struct Probe: Codable, Sendable {
|
||||||
struct Bot: Codable, Sendable {
|
struct Bot: Codable, Sendable {
|
||||||
let username: String?
|
let username: String?
|
||||||
@@ -44,9 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
|
|||||||
let ok: Bool?
|
let ok: Bool?
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let durationMs: Double
|
let durationMs: Double
|
||||||
let channels: [String: ChannelSummary]
|
let providers: [String: ProviderSummary]
|
||||||
let channelOrder: [String]?
|
let providerOrder: [String]?
|
||||||
let channelLabels: [String: String]?
|
let providerLabels: [String: String]?
|
||||||
let heartbeatSeconds: Int?
|
let heartbeatSeconds: Int?
|
||||||
let sessions: Sessions
|
let sessions: Sessions
|
||||||
}
|
}
|
||||||
@@ -144,13 +144,13 @@ final class HealthStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool {
|
private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool {
|
||||||
guard summary.configured == true else { return false }
|
guard summary.configured == true else { return false }
|
||||||
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||||
return summary.probe?.ok ?? true
|
return summary.probe?.ok ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
|
private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String {
|
||||||
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
||||||
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
||||||
if let elapsed { return "Health check timed out (\(elapsed))" }
|
if let elapsed { return "Health check timed out (\(elapsed))" }
|
||||||
@@ -162,28 +162,28 @@ final class HealthStore {
|
|||||||
return "\(reason) (\(code))"
|
return "\(reason) (\(code))"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLinkChannel(
|
private func resolveLinkProvider(
|
||||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||||
{
|
{
|
||||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||||
for id in order {
|
for id in order {
|
||||||
if let summary = snap.channels[id], summary.linked != nil {
|
if let summary = snap.providers[id], summary.linked != nil {
|
||||||
return (id: id, summary: summary)
|
return (id: id, summary: summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveFallbackChannel(
|
private func resolveFallbackProvider(
|
||||||
_ snap: HealthSnapshot,
|
_ snap: HealthSnapshot,
|
||||||
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||||
{
|
{
|
||||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||||
for channelId in order {
|
for providerId in order {
|
||||||
if channelId == id { continue }
|
if providerId == id { continue }
|
||||||
guard let summary = snap.channels[channelId] else { continue }
|
guard let summary = snap.providers[providerId] else { continue }
|
||||||
if Self.isChannelHealthy(summary) {
|
if Self.isProviderHealthy(summary) {
|
||||||
return (id: channelId, summary: summary)
|
return (id: providerId, summary: summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -194,13 +194,13 @@ final class HealthStore {
|
|||||||
return .degraded(error)
|
return .degraded(error)
|
||||||
}
|
}
|
||||||
guard let snap = self.snapshot else { return .unknown }
|
guard let snap = self.snapshot else { return .unknown }
|
||||||
guard let link = self.resolveLinkChannel(snap) else { return .unknown }
|
guard let link = self.resolveLinkProvider(snap) else { return .unknown }
|
||||||
if link.summary.linked != true {
|
if link.summary.linked != true {
|
||||||
// Linking is optional if any other channel is healthy; don't paint the whole app red.
|
// Linking is optional if any other provider is healthy; don't paint the whole app red.
|
||||||
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
|
let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
|
||||||
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
||||||
}
|
}
|
||||||
// A channel can be "linked" but still unhealthy (failed probe / cannot connect).
|
// A provider can be "linked" but still unhealthy (failed probe / cannot connect).
|
||||||
if let probe = link.summary.probe, probe.ok == false {
|
if let probe = link.summary.probe, probe.ok == false {
|
||||||
return .degraded(Self.describeProbeFailure(probe))
|
return .degraded(Self.describeProbeFailure(probe))
|
||||||
}
|
}
|
||||||
@@ -211,10 +211,10 @@ final class HealthStore {
|
|||||||
if self.isRefreshing { return "Health check running…" }
|
if self.isRefreshing { return "Health check running…" }
|
||||||
if let error = self.lastError { return "Health check failed: \(error)" }
|
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||||
guard let snap = self.snapshot else { return "Health check pending" }
|
guard let snap = self.snapshot else { return "Health check pending" }
|
||||||
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" }
|
guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" }
|
||||||
if link.summary.linked != true {
|
if link.summary.linked != true {
|
||||||
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
|
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
|
||||||
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
|
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
|
||||||
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
||||||
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
|
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
|
||||||
}
|
}
|
||||||
@@ -247,10 +247,10 @@ final class HealthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||||
if let link = self.resolveLinkChannel(snap), link.summary.linked != true {
|
if let link = self.resolveLinkProvider(snap), link.summary.linked != true {
|
||||||
return "Not linked — run clawdbot login"
|
return "Not linked — run clawdbot login"
|
||||||
}
|
}
|
||||||
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false {
|
if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false {
|
||||||
return Self.describeProbeFailure(probe)
|
return Self.describeProbeFailure(probe)
|
||||||
}
|
}
|
||||||
if let fallback, !fallback.isEmpty {
|
if let fallback, !fallback.isEmpty {
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
|||||||
host: "phone",
|
host: "phone",
|
||||||
ip: "10.0.0.3",
|
ip: "10.0.0.3",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
platform: "iOS 18.0",
|
platform: "iOS 17.2",
|
||||||
deviceFamily: "iPhone",
|
deviceFamily: "iPhone",
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
lastInputSeconds: 35,
|
lastInputSeconds: 35,
|
||||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
|||||||
_ = view.platformIcon("watchOS 10")
|
_ = view.platformIcon("watchOS 10")
|
||||||
_ = view.platformIcon("unknown 1.0")
|
_ = view.platformIcon("unknown 1.0")
|
||||||
_ = view.prettyPlatform("macOS 14.2")
|
_ = view.prettyPlatform("macOS 14.2")
|
||||||
_ = view.prettyPlatform("iOS 18")
|
_ = view.prettyPlatform("iOS 17")
|
||||||
_ = view.prettyPlatform("ipados 17.1")
|
_ = view.prettyPlatform("ipados 17.1")
|
||||||
_ = view.prettyPlatform("linux")
|
_ = view.prettyPlatform("linux")
|
||||||
_ = view.prettyPlatform(" ")
|
_ = view.prettyPlatform(" ")
|
||||||
|
|||||||
@@ -242,18 +242,18 @@ final class InstancesStore {
|
|||||||
do {
|
do {
|
||||||
let data = try await ControlChannel.shared.health(timeout: 8)
|
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||||
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||||
let linkId = snap.channelOrder?.first(where: {
|
let linkId = snap.providerOrder?.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
}) ?? snap.channels.keys.first(where: {
|
}) ?? snap.providers.keys.first(where: {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||||
let linkLabel =
|
let linkLabel =
|
||||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||||
linkId?.capitalized ??
|
linkId?.capitalized ??
|
||||||
"channel"
|
"provider"
|
||||||
let entry = InstanceInfo(
|
let entry = InstanceInfo(
|
||||||
id: "health-\(snap.ts)",
|
id: "health-\(snap.ts)",
|
||||||
host: "gateway (health)",
|
host: "gateway (health)",
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ enum Launchctl {
|
|||||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||||
let programArguments: [String]
|
let programArguments: [String]
|
||||||
let environment: [String: String]
|
let environment: [String: String]
|
||||||
let stdoutPath: String?
|
|
||||||
let stderrPath: String?
|
|
||||||
|
|
||||||
let port: Int?
|
let port: Int?
|
||||||
let bind: String?
|
let bind: String?
|
||||||
@@ -55,10 +53,6 @@ enum LaunchAgentPlist {
|
|||||||
guard let root = rootAny as? [String: Any] else { return nil }
|
guard let root = rootAny as? [String: Any] else { return nil }
|
||||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||||
let stdoutPath = (root["StandardOutPath"] as? String)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
||||||
let stderrPath = (root["StandardErrorPath"] as? String)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
||||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
@@ -66,8 +60,6 @@ enum LaunchAgentPlist {
|
|||||||
return LaunchAgentPlistSnapshot(
|
return LaunchAgentPlistSnapshot(
|
||||||
programArguments: programArguments,
|
programArguments: programArguments,
|
||||||
environment: env,
|
environment: env,
|
||||||
stdoutPath: stdoutPath,
|
|
||||||
stderrPath: stderrPath,
|
|
||||||
port: port,
|
port: port,
|
||||||
bind: bind,
|
bind: bind,
|
||||||
token: token,
|
token: token,
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum LogLocator {
|
enum LogLocator {
|
||||||
private static var logDir: URL {
|
private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
|
||||||
if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty {
|
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
|
||||||
return URL(fileURLWithPath: override)
|
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
|
||||||
}
|
|
||||||
|
|
||||||
return URL(fileURLWithPath: "/tmp/clawdbot")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var stdoutLog: URL {
|
|
||||||
logDir.appendingPathComponent("clawdbot-stdout.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var gatewayLog: URL {
|
|
||||||
logDir.appendingPathComponent("clawdbot-gateway.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func ensureLogDirExists() {
|
|
||||||
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func modificationDate(for url: URL) -> Date {
|
private static func modificationDate(for url: URL) -> Date {
|
||||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||||
@@ -27,7 +11,6 @@ enum LogLocator {
|
|||||||
|
|
||||||
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
||||||
static func bestLogFile() -> URL? {
|
static func bestLogFile() -> URL? {
|
||||||
self.ensureLogDirExists()
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let files = (try? fm.contentsOfDirectory(
|
let files = (try? fm.contentsOfDirectory(
|
||||||
at: self.logDir,
|
at: self.logDir,
|
||||||
@@ -43,13 +26,11 @@ enum LogLocator {
|
|||||||
|
|
||||||
/// Path to use for launchd stdout/err.
|
/// Path to use for launchd stdout/err.
|
||||||
static var launchdLogPath: String {
|
static var launchdLogPath: String {
|
||||||
self.ensureLogDirExists()
|
stdoutLog.path
|
||||||
return stdoutLog.path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to use for the Gateway launchd job stdout/err.
|
/// Path to use for the Gateway launchd job stdout/err.
|
||||||
static var launchdGatewayLogPath: String {
|
static var launchdGatewayLogPath: String {
|
||||||
self.ensureLogDirExists()
|
gatewayLog.path
|
||||||
return gatewayLog.path
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
enum MacNodeConfigFile {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
|
||||||
|
|
||||||
static func url() -> URL {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
|
||||||
let url = self.url()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
self.logger.warning("mac node config JSON root invalid")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
} catch {
|
|
||||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
let url = self.url()
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
let raw = systemRun?["policy"] as? String
|
|
||||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
systemRun["policy"] = policy.rawValue
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAllowlist() -> [String]? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
return systemRun?["allowlist"] as? [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
if cleaned.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
systemRun["allowlist"] = cleaned
|
|
||||||
}
|
|
||||||
if systemRun.isEmpty {
|
|
||||||
root.removeValue(forKey: "systemRun")
|
|
||||||
} else {
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,12 +31,6 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
|
||||||
Binding(
|
|
||||||
get: { self.state.systemRunPolicy },
|
|
||||||
set: { self.state.systemRunPolicy = $0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) {
|
Toggle(isOn: self.activeBinding) {
|
||||||
@@ -74,13 +68,6 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
Label("Allow Camera", systemImage: "camera")
|
||||||
}
|
}
|
||||||
Picker(selection: self.systemRunPolicyBinding) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Node Run Commands", systemImage: "terminal")
|
|
||||||
}
|
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
}
|
}
|
||||||
@@ -115,14 +102,11 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
if self.state.canvasEnabled {
|
if self.state.canvasEnabled {
|
||||||
Button {
|
Button {
|
||||||
Task { @MainActor in
|
if self.state.canvasPanelVisible {
|
||||||
if self.state.canvasPanelVisible {
|
CanvasManager.shared.hideAll()
|
||||||
CanvasManager.shared.hideAll()
|
} else {
|
||||||
} else {
|
// Don't force a navigation on re-open: preserve the current web view state.
|
||||||
let sessionKey = await GatewayConnection.shared.mainSessionKey()
|
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||||
// Don't force a navigation on re-open: preserve the current web view state.
|
|
||||||
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
@@ -329,7 +313,27 @@ struct MenuContent: View {
|
|||||||
private func openDashboard() async {
|
private func openDashboard() async {
|
||||||
do {
|
do {
|
||||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||||
let url = try GatewayEndpointStore.dashboardURL(for: config)
|
let wsURL = config.url
|
||||||
|
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
|
||||||
|
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
switch components.scheme?.lowercased() {
|
||||||
|
case "ws":
|
||||||
|
components.scheme = "http"
|
||||||
|
case "wss":
|
||||||
|
components.scheme = "https"
|
||||||
|
default:
|
||||||
|
components.scheme = "http"
|
||||||
|
}
|
||||||
|
components.path = "/"
|
||||||
|
components.query = nil
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
||||||
|
])
|
||||||
|
}
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
extension MenuSessionsInjector {
|
extension MenuSessionsInjector {
|
||||||
// MARK: - Injection
|
// MARK: - Injection
|
||||||
|
|
||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
// Remove any previous injected items.
|
// Remove any previous injected items.
|
||||||
for item in menu.items where item.tag == self.tag {
|
for item in menu.items where item.tag == self.tag {
|
||||||
@@ -122,15 +120,13 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
if let snapshot = self.cachedSnapshot {
|
if let snapshot = self.cachedSnapshot {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let mainKey = self.mainSessionKey
|
|
||||||
let rows = snapshot.rows.filter { row in
|
let rows = snapshot.rows.filter { row in
|
||||||
if row.key == "main", mainKey != "main" { return false }
|
if row.key == "main" { return true }
|
||||||
if row.key == mainKey { return true }
|
|
||||||
guard let updatedAt = row.updatedAt else { return false }
|
guard let updatedAt = row.updatedAt else { return false }
|
||||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||||
}.sorted { lhs, rhs in
|
}.sorted { lhs, rhs in
|
||||||
if lhs.key == mainKey { return true }
|
if lhs.key == "main" { return true }
|
||||||
if rhs.key == mainKey { return false }
|
if rhs.key == "main" { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +645,7 @@ extension MenuSessionsInjector {
|
|||||||
compact.representedObject = row.key
|
compact.representedObject = row.key
|
||||||
menu.addItem(compact)
|
menu.addItem(compact)
|
||||||
|
|
||||||
if row.key != self.mainSessionKey, row.key != "global" {
|
if row.key != "main", row.key != "global" {
|
||||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||||
del.target = self
|
del.target = self
|
||||||
del.representedObject = row.key
|
del.representedObject = row.key
|
||||||
|
|||||||
@@ -11,39 +11,10 @@ actor MacNodeBridgePairingClient {
|
|||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
silent: Bool,
|
silent: Bool,
|
||||||
tls: MacNodeBridgeTLSParams? = nil,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
silent: silent,
|
|
||||||
tls: tls,
|
|
||||||
onStatus: onStatus)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
silent: silent,
|
|
||||||
tls: nil,
|
|
||||||
onStatus: onStatus)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pairAndHelloOnce(
|
|
||||||
endpoint: NWEndpoint,
|
|
||||||
hello: BridgeHello,
|
|
||||||
silent: Bool,
|
|
||||||
tls: MacNodeBridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let params = self.makeParameters(tls: tls)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
try await AsyncTimeout.withTimeout(
|
try await AsyncTimeout.withTimeout(
|
||||||
@@ -193,18 +164,6 @@ actor MacNodeBridgePairingClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startAndWaitForReady(
|
private func startAndWaitForReady(
|
||||||
_ connection: NWConnection,
|
_ connection: NWConnection,
|
||||||
queue: DispatchQueue) async throws
|
queue: DispatchQueue) async throws
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ actor MacNodeBridgeSession {
|
|||||||
func connect(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: MacNodeBridgeTLSParams? = nil,
|
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
|
||||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
async throws
|
async throws
|
||||||
@@ -45,35 +44,15 @@ actor MacNodeBridgeSession {
|
|||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.disconnectHandler = onDisconnected
|
self.disconnectHandler = onDisconnected
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
do {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectOnce(
|
let params = NWParameters.tcp
|
||||||
endpoint: NWEndpoint,
|
params.includePeerToPeer = true
|
||||||
hello: BridgeHello,
|
let tcpOptions = NWProtocolTCP.Options()
|
||||||
tls: MacNodeBridgeTLSParams?,
|
tcpOptions.enableKeepalive = true
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
tcpOptions.keepaliveIdle = 30
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
tcpOptions.keepaliveInterval = 15
|
||||||
{
|
tcpOptions.keepaliveCount = 3
|
||||||
let params = self.makeParameters(tls: tls)
|
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -119,8 +98,7 @@ actor MacNodeBridgeSession {
|
|||||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||||
self.state = .connected(serverName: ok.serverName)
|
self.state = .connected(serverName: ok.serverName)
|
||||||
self.startPingLoop()
|
self.startPingLoop()
|
||||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
await onConnected?(ok.serverName)
|
||||||
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
|
|
||||||
} else if base.type == "error" {
|
} else if base.type == "error" {
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||||
@@ -283,25 +261,6 @@ actor MacNodeBridgeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
tcpOptions.enableKeepalive = true
|
|
||||||
tcpOptions.keepaliveIdle = 30
|
|
||||||
tcpOptions.keepaliveInterval = 15
|
|
||||||
tcpOptions.keepaliveCount = 3
|
|
||||||
|
|
||||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func failRPC(id: String, error: Error) async {
|
private func failRPC(id: String, error: Error) async {
|
||||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
@@ -460,7 +419,7 @@ actor MacNodeBridgeSession {
|
|||||||
do {
|
do {
|
||||||
try await self.send(response)
|
try await self.send(response)
|
||||||
} catch {
|
} catch {
|
||||||
self.logInvokeSendFailure(error)
|
await self.logInvokeSendFailure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct MacNodeBridgeTLSParams: Sendable {
|
|
||||||
let required: Bool
|
|
||||||
let expectedFingerprint: String?
|
|
||||||
let allowTOFU: Bool
|
|
||||||
let storeKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MacNodeBridgeTLSStore {
|
|
||||||
private static let suiteName = "com.clawdbot.shared"
|
|
||||||
private static let keyPrefix = "mac.node.bridge.tls."
|
|
||||||
|
|
||||||
private static var defaults: UserDefaults {
|
|
||||||
UserDefaults(suiteName: suiteName) ?? .standard
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
|
||||||
let key = self.keyPrefix + stableID
|
|
||||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return raw?.isEmpty == false ? raw : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
|
||||||
let key = self.keyPrefix + stableID
|
|
||||||
self.defaults.set(value, forKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
|
|
||||||
guard let params else { return nil }
|
|
||||||
let options = NWProtocolTLS.Options()
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
|
|
||||||
let allowTOFU = params.allowTOFU
|
|
||||||
let storeKey = params.storeKey
|
|
||||||
|
|
||||||
sec_protocol_options_set_verify_block(
|
|
||||||
options.securityProtocolOptions,
|
|
||||||
{ _, trust, complete in
|
|
||||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
|
||||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
|
||||||
let cert = chain.first
|
|
||||||
{
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
|
||||||
let fingerprint = sha256Hex(data)
|
|
||||||
if let expected {
|
|
||||||
complete(fingerprint == expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if allowTOFU {
|
|
||||||
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
|
||||||
complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
|
||||||
complete(ok)
|
|
||||||
},
|
|
||||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
|
||||||
raw.lowercased().filter(\.isHexDigit)
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,6 @@ import Foundation
|
|||||||
import Network
|
import Network
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private struct BridgeTarget {
|
|
||||||
let endpoint: NWEndpoint
|
|
||||||
let stableID: String
|
|
||||||
let tls: MacNodeBridgeTLSParams?
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MacNodeModeCoordinator {
|
final class MacNodeModeCoordinator {
|
||||||
static let shared = MacNodeModeCoordinator()
|
static let shared = MacNodeModeCoordinator()
|
||||||
@@ -43,7 +37,6 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
var lastSystemRunPolicy: SystemRunPolicy?
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@@ -60,16 +53,7 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
let systemRunPolicy = SystemRunPolicy.load()
|
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
if lastSystemRunPolicy == nil {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
|
||||||
lastSystemRunPolicy = systemRunPolicy
|
|
||||||
await self.session.disconnect()
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
continue
|
continue
|
||||||
@@ -79,16 +63,12 @@ final class MacNodeModeCoordinator {
|
|||||||
do {
|
do {
|
||||||
let hello = await self.makeHello()
|
let hello = await self.makeHello()
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||||
try await self.session.connect(
|
try await self.session.connect(
|
||||||
endpoint: target.endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: target.tls,
|
onConnected: { [weak self] serverName in
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
|
||||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||||
if let mainSessionKey {
|
|
||||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDisconnected: { reason in
|
onDisconnected: { reason in
|
||||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||||
@@ -103,7 +83,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return await self.runtime.handleInvoke(req)
|
return await self.runtime.handleInvoke(req)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
if await self.tryPair(target: target, error: error) {
|
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -160,14 +140,10 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
if SystemRunPolicy.load() != .never {
|
|
||||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
|
||||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
@@ -181,7 +157,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||||
let text = error.localizedDescription.uppercased()
|
let text = error.localizedDescription.uppercased()
|
||||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||||
|
|
||||||
@@ -191,10 +167,9 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
let hello = await self.makeHello()
|
let hello = await self.makeHello()
|
||||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||||
endpoint: target.endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
silent: shouldSilent,
|
silent: shouldSilent,
|
||||||
tls: target.tls,
|
|
||||||
onStatus: { [weak self] status in
|
onStatus: { [weak self] status in
|
||||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||||
})
|
})
|
||||||
@@ -212,7 +187,7 @@ final class MacNodeModeCoordinator {
|
|||||||
"mac-\(InstanceIdentity.instanceId)"
|
"mac-\(InstanceIdentity.instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
guard let port = Self.loopbackBridgePort(),
|
guard let port = Self.loopbackBridgePort(),
|
||||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||||
else {
|
else {
|
||||||
@@ -220,10 +195,7 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||||
guard reachable else { return nil }
|
return reachable ? endpoint : nil
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loopbackBridgePort() -> UInt16? {
|
static func loopbackBridgePort() -> UInt16? {
|
||||||
@@ -316,7 +288,7 @@ final class MacNodeModeCoordinator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||||
if mode == .remote {
|
if mode == .remote {
|
||||||
do {
|
do {
|
||||||
@@ -328,10 +300,7 @@ final class MacNodeModeCoordinator {
|
|||||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
return .hostPort(host: "127.0.0.1", port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||||
@@ -340,23 +309,9 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remotePort = Self.remoteBridgePort()
|
let remotePort = Self.remoteBridgePort()
|
||||||
let preferredLocalPort = Self.loopbackBridgePort()
|
|
||||||
if let preferredLocalPort {
|
|
||||||
self.logger.info(
|
|
||||||
"mac node bridge tunnel starting " +
|
|
||||||
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
|
||||||
} else {
|
|
||||||
self.logger.info(
|
|
||||||
"mac node bridge tunnel starting " +
|
|
||||||
"preferredLocalPort=none " +
|
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
|
||||||
}
|
|
||||||
self.tunnel = try await RemotePortTunnel.create(
|
self.tunnel = try await RemotePortTunnel.create(
|
||||||
remotePort: remotePort,
|
remotePort: remotePort,
|
||||||
preferredLocalPort: preferredLocalPort,
|
allowRemoteUrlOverride: false)
|
||||||
allowRemoteUrlOverride: false,
|
|
||||||
allowRandomLocalPort: true)
|
|
||||||
if let localPort = self.tunnel?.localPort,
|
if let localPort = self.tunnel?.localPort,
|
||||||
let port = NWEndpoint.Port(rawValue: localPort)
|
let port = NWEndpoint.Port(rawValue: localPort)
|
||||||
{
|
{
|
||||||
@@ -364,10 +319,7 @@ final class MacNodeModeCoordinator {
|
|||||||
"mac node bridge tunnel ready " +
|
"mac node bridge tunnel ready " +
|
||||||
"localPort=\(localPort, privacy: .public) " +
|
"localPort=\(localPort, privacy: .public) " +
|
||||||
"remotePort=\(remotePort, privacy: .public)")
|
"remotePort=\(remotePort, privacy: .public)")
|
||||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
return .hostPort(host: "127.0.0.1", port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -378,8 +330,8 @@ final class MacNodeModeCoordinator {
|
|||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.tunnel = nil
|
self.tunnel = nil
|
||||||
}
|
}
|
||||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||||
return target
|
return endpoint
|
||||||
}
|
}
|
||||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||||
}
|
}
|
||||||
@@ -399,14 +351,14 @@ final class MacNodeModeCoordinator {
|
|||||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
final class DiscoveryState: @unchecked Sendable {
|
final class DiscoveryState: @unchecked Sendable {
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
var resolved = false
|
var resolved = false
|
||||||
var browsers: [NWBrowser] = []
|
var browsers: [NWBrowser] = []
|
||||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||||
|
|
||||||
func finish(_ target: BridgeTarget?) {
|
func finish(_ endpoint: NWEndpoint?) {
|
||||||
self.lock.lock()
|
self.lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if self.resolved { return }
|
if self.resolved { return }
|
||||||
@@ -414,7 +366,7 @@ final class MacNodeModeCoordinator {
|
|||||||
for browser in self.browsers {
|
for browser in self.browsers {
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
}
|
}
|
||||||
self.continuation?.resume(returning: target)
|
self.continuation?.resume(returning: endpoint)
|
||||||
self.continuation = nil
|
self.continuation = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,12 +392,12 @@ final class MacNodeModeCoordinator {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
state.finish(Self.targetFromResult(match))
|
state.finish(match.endpoint)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||||
state.finish(Self.targetFromResult(result))
|
state.finish(result.endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
browser.stateUpdateHandler = { browserState in
|
browser.stateUpdateHandler = { browserState in
|
||||||
@@ -463,72 +415,6 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
|
||||||
let endpoint = result.endpoint
|
|
||||||
guard case .service = endpoint else { return nil }
|
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
|
||||||
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
|
|
||||||
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
|
|
||||||
let tlsParams = Self.resolveDiscoveredTLSParams(
|
|
||||||
stableID: stableID,
|
|
||||||
tlsEnabled: tlsEnabled,
|
|
||||||
tlsFingerprintSha256: tlsFingerprint)
|
|
||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func resolveDiscoveredTLSParams(
|
|
||||||
stableID: String,
|
|
||||||
tlsEnabled: Bool,
|
|
||||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if tlsEnabled || tlsFingerprintSha256 != nil {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
|
||||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return MacNodeBridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
|
||||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return raw.isEmpty ? nil : raw
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MacNodeTokenStore {
|
enum MacNodeTokenStore {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ actor MacNodeRuntime {
|
|||||||
private let cameraCapture = CameraCaptureService()
|
private let cameraCapture = CameraCaptureService()
|
||||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||||
private var mainSessionKey: String = "main"
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||||
@@ -17,12 +16,6 @@ actor MacNodeRuntime {
|
|||||||
self.makeMainActorServices = makeMainActorServices
|
self.makeMainActorServices = makeMainActorServices
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMainSessionKey(_ sessionKey: String) {
|
|
||||||
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
self.mainSessionKey = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||||
@@ -55,8 +48,6 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleScreenRecordInvoke(req)
|
return try await self.handleScreenRecordInvoke(req)
|
||||||
case ClawdbotSystemCommand.run.rawValue:
|
case ClawdbotSystemCommand.run.rawValue:
|
||||||
return try await self.handleSystemRun(req)
|
return try await self.handleSystemRun(req)
|
||||||
case ClawdbotSystemCommand.which.rawValue:
|
|
||||||
return try await self.handleSystemWhich(req)
|
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
default:
|
default:
|
||||||
@@ -81,32 +72,28 @@ actor MacNodeRuntime {
|
|||||||
let placement = params.placement.map {
|
let placement = params.placement.map {
|
||||||
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
|
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
|
||||||
}
|
}
|
||||||
let sessionKey = self.mainSessionKey
|
|
||||||
try await MainActor.run {
|
try await MainActor.run {
|
||||||
_ = try CanvasManager.shared.showDetailed(
|
_ = try CanvasManager.shared.showDetailed(
|
||||||
sessionKey: sessionKey,
|
sessionKey: "main",
|
||||||
target: url,
|
target: url,
|
||||||
placement: placement)
|
placement: placement)
|
||||||
}
|
}
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
case ClawdbotCanvasCommand.hide.rawValue:
|
case ClawdbotCanvasCommand.hide.rawValue:
|
||||||
let sessionKey = self.mainSessionKey
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
CanvasManager.shared.hide(sessionKey: sessionKey)
|
CanvasManager.shared.hide(sessionKey: "main")
|
||||||
}
|
}
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
case ClawdbotCanvasCommand.navigate.rawValue:
|
case ClawdbotCanvasCommand.navigate.rawValue:
|
||||||
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
|
||||||
let sessionKey = self.mainSessionKey
|
|
||||||
try await MainActor.run {
|
try await MainActor.run {
|
||||||
_ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url)
|
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
|
||||||
}
|
}
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
case ClawdbotCanvasCommand.evalJS.rawValue:
|
case ClawdbotCanvasCommand.evalJS.rawValue:
|
||||||
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
|
||||||
let sessionKey = self.mainSessionKey
|
|
||||||
let result = try await CanvasManager.shared.eval(
|
let result = try await CanvasManager.shared.eval(
|
||||||
sessionKey: sessionKey,
|
sessionKey: "main",
|
||||||
javaScript: params.javaScript)
|
javaScript: params.javaScript)
|
||||||
let payload = try Self.encodePayload(["result": result] as [String: String])
|
let payload = try Self.encodePayload(["result": result] as [String: String])
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
@@ -122,8 +109,7 @@ actor MacNodeRuntime {
|
|||||||
}()
|
}()
|
||||||
let quality = params?.quality ?? 0.9
|
let quality = params?.quality ?? 0.9
|
||||||
|
|
||||||
let sessionKey = self.mainSessionKey
|
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
|
||||||
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
guard let image = NSImage(data: data) else {
|
guard let image = NSImage(data: data) else {
|
||||||
@@ -333,8 +319,7 @@ actor MacNodeRuntime {
|
|||||||
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
try await self.ensureA2UIHost()
|
try await self.ensureA2UIHost()
|
||||||
|
|
||||||
let sessionKey = self.mainSessionKey
|
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||||
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
|
|
||||||
(() => {
|
(() => {
|
||||||
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
|
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
|
||||||
return JSON.stringify(globalThis.clawdbotA2UI.reset());
|
return JSON.stringify(globalThis.clawdbotA2UI.reset());
|
||||||
@@ -373,8 +358,7 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
"""
|
"""
|
||||||
let sessionKey = self.mainSessionKey
|
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
|
||||||
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,9 +369,8 @@ actor MacNodeRuntime {
|
|||||||
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
let sessionKey = self.mainSessionKey
|
|
||||||
_ = try await MainActor.run {
|
_ = try await MainActor.run {
|
||||||
try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl)
|
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
|
||||||
}
|
}
|
||||||
if await self.isA2UIReady(poll: true) { return }
|
if await self.isA2UIReady(poll: true) { return }
|
||||||
throw NSError(domain: "Canvas", code: 31, userInfo: [
|
throw NSError(domain: "Canvas", code: 31, userInfo: [
|
||||||
@@ -406,8 +389,7 @@ actor MacNodeRuntime {
|
|||||||
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
|
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
|
||||||
while true {
|
while true {
|
||||||
do {
|
do {
|
||||||
let sessionKey = self.mainSessionKey
|
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||||
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
|
|
||||||
(() => String(Boolean(globalThis.clawdbotA2UI)))()
|
(() => String(Boolean(globalThis.clawdbotA2UI)))()
|
||||||
""")
|
""")
|
||||||
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -428,37 +410,6 @@ actor MacNodeRuntime {
|
|||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
|
||||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
|
||||||
switch Self.systemRunPolicy() {
|
|
||||||
case .never:
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
|
||||||
case .always:
|
|
||||||
break
|
|
||||||
case .ask:
|
|
||||||
if !wasAllowlisted {
|
|
||||||
let services = await self.mainActorServices()
|
|
||||||
let decision = await services.confirmSystemRun(
|
|
||||||
command: SystemRunAllowlist.displayString(for: command),
|
|
||||||
cwd: params.cwd)
|
|
||||||
switch decision {
|
|
||||||
case .allowOnce:
|
|
||||||
break
|
|
||||||
case .allowAlways:
|
|
||||||
SystemRunAllowlist.add(command)
|
|
||||||
case .deny:
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -474,7 +425,7 @@ actor MacNodeRuntime {
|
|||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: params.env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
@@ -496,33 +447,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
|
|
||||||
let bins = params.bins
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard !bins.isEmpty else {
|
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchPaths = CommandResolver.preferredPaths()
|
|
||||||
var matches: [String] = []
|
|
||||||
var paths: [String: String] = [:]
|
|
||||||
for bin in bins {
|
|
||||||
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
|
||||||
matches.append(bin)
|
|
||||||
paths[bin] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WhichPayload: Encodable {
|
|
||||||
let bins: [String]
|
|
||||||
let paths: [String: String]
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -589,39 +513,6 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
|
||||||
SystemRunPolicy.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
|
||||||
"PATH",
|
|
||||||
"NODE_OPTIONS",
|
|
||||||
"PYTHONHOME",
|
|
||||||
"PYTHONPATH",
|
|
||||||
"PERL5LIB",
|
|
||||||
"PERL5OPT",
|
|
||||||
"RUBYOPT",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let blockedEnvPrefixes: [String] = [
|
|
||||||
"DYLD_",
|
|
||||||
"LD_",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
|
||||||
guard let overrides else { return nil }
|
|
||||||
var merged = ProcessInfo.processInfo.environment
|
|
||||||
for (rawKey, value) in overrides {
|
|
||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !key.isEmpty else { continue }
|
|
||||||
let upper = key.uppercased()
|
|
||||||
if self.blockedEnvKeys.contains(upper) { continue }
|
|
||||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import AppKit
|
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SystemRunDecision: Sendable {
|
|
||||||
case allowOnce
|
|
||||||
case allowAlways
|
|
||||||
case deny
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -66,31 +57,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
maxAgeMs: maxAgeMs,
|
maxAgeMs: maxAgeMs,
|
||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.messageText = "Allow this command?"
|
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
|
||||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this Mac via node mode."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
|
||||||
alert.addButton(withTitle: "Always Allow")
|
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
|
||||||
case .alertFirstButtonReturn:
|
|
||||||
return .allowOnce
|
|
||||||
case .alertSecondButtonReturn:
|
|
||||||
return .allowAlways
|
|
||||||
default:
|
|
||||||
return .deny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -694,7 +694,7 @@ extension OnboardingView {
|
|||||||
systemImage: "bubble.left.and.bubble.right")
|
systemImage: "bubble.left.and.bubble.right")
|
||||||
self.featureActionRow(
|
self.featureActionRow(
|
||||||
title: "Connect WhatsApp or Telegram",
|
title: "Connect WhatsApp or Telegram",
|
||||||
subtitle: "Open Settings → Connections to link channels and monitor status.",
|
subtitle: "Open Settings → Connections to link providers and monitor status.",
|
||||||
systemImage: "link")
|
systemImage: "link")
|
||||||
{
|
{
|
||||||
self.openSettings(tab: .connections)
|
self.openSettings(tab: .connections)
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ final class OnboardingWizardModel {
|
|||||||
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
||||||
guard self.sessionId == nil, !self.isStarting else { return }
|
guard self.sessionId == nil, !self.isStarting else { return }
|
||||||
guard mode == .local else { return }
|
guard mode == .local else { return }
|
||||||
if self.shouldSkipWizard() {
|
|
||||||
self.sessionId = nil
|
|
||||||
self.currentStep = nil
|
|
||||||
self.status = "done"
|
|
||||||
self.errorMessage = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.isStarting = true
|
self.isStarting = true
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
self.lastStartMode = mode
|
self.lastStartMode = mode
|
||||||
@@ -184,33 +177,6 @@ final class OnboardingWizardModel {
|
|||||||
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
|
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldSkipWizard() -> Bool {
|
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
|
||||||
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any]
|
|
||||||
{
|
|
||||||
if let mode = auth["mode"] as? String,
|
|
||||||
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let token = auth["token"] as? String,
|
|
||||||
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let password = auth["password"] as? String,
|
|
||||||
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OnboardingWizardStepView: View {
|
struct OnboardingWizardStepView: View {
|
||||||
|
|||||||
@@ -351,11 +351,10 @@ actor PortGuardian {
|
|||||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
return false
|
return false
|
||||||
case .local:
|
case .local:
|
||||||
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
|
if !cmd.contains("clawdbot") { return false }
|
||||||
if full.contains("gateway-daemon") { return true }
|
if full.contains("gateway-daemon") { return true }
|
||||||
// If args are unavailable, treat a clawdbot listener as expected.
|
// If args are unavailable, treat a clawdbot listener as expected.
|
||||||
if cmd.contains("clawdbot"), full == cmd { return true }
|
return full == cmd
|
||||||
return false
|
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|
||||||
case never
|
|
||||||
case ask
|
|
||||||
case always
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .never:
|
|
||||||
"Never"
|
|
||||||
case .ask:
|
|
||||||
"Always Ask"
|
|
||||||
case .always:
|
|
||||||
"Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
|
||||||
let policy = SystemRunPolicy(rawValue: raw)
|
|
||||||
{
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
|
||||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
let fallback: SystemRunPolicy = .ask
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlist {
|
|
||||||
static func key(for argv: [String]) -> String {
|
|
||||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !trimmed.isEmpty else { return "" }
|
|
||||||
if let data = try? JSONEncoder().encode(trimmed),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
return trimmed.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayString(for argv: [String]) -> String {
|
|
||||||
argv.map { arg in
|
|
||||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "\"\"" }
|
|
||||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
|
||||||
if !needsQuotes { return trimmed }
|
|
||||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
return "\"\(escaped)\""
|
|
||||||
}.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
|
||||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
|
||||||
return Set(allowlist)
|
|
||||||
}
|
|
||||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
|
||||||
return Set(legacy)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
|
||||||
let key = key(for: argv)
|
|
||||||
return self.load(from: defaults).contains(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
|
||||||
let key = key(for: argv)
|
|
||||||
guard !key.isEmpty else { return }
|
|
||||||
var allowlist = self.load(from: defaults)
|
|
||||||
if allowlist.insert(key).inserted {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ enum VoiceWakeForwarder {
|
|||||||
var thinking: String = "low"
|
var thinking: String = "low"
|
||||||
var deliver: Bool = true
|
var deliver: Bool = true
|
||||||
var to: String?
|
var to: String?
|
||||||
var channel: GatewayAgentChannel = .last
|
var provider: GatewayAgentProvider = .last
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -46,14 +46,14 @@ enum VoiceWakeForwarder {
|
|||||||
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||||
{
|
{
|
||||||
let payload = Self.prefixedTranscript(transcript)
|
let payload = Self.prefixedTranscript(transcript)
|
||||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
let deliver = options.provider.shouldDeliver(options.deliver)
|
||||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: payload,
|
message: payload,
|
||||||
sessionKey: options.sessionKey,
|
sessionKey: options.sessionKey,
|
||||||
thinking: options.thinking,
|
thinking: options.thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
channel: options.channel))
|
provider: options.provider))
|
||||||
|
|
||||||
if result.ok {
|
if result.ok {
|
||||||
self.logger.info("voice wake forward ok")
|
self.logger.info("voice wake forward ok")
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ final class WorkActivityStore {
|
|||||||
private var currentSessionKey: String?
|
private var currentSessionKey: String?
|
||||||
private var toolSeqBySession: [String: Int] = [:]
|
private var toolSeqBySession: [String: Int] = [:]
|
||||||
|
|
||||||
private var mainSessionKeyStorage = "main"
|
private let mainSessionKey = "main"
|
||||||
private let toolResultGrace: TimeInterval = 2.0
|
private let toolResultGrace: TimeInterval = 2.0
|
||||||
|
|
||||||
var mainSessionKey: String { self.mainSessionKeyStorage }
|
|
||||||
|
|
||||||
func handleJob(sessionKey: String, state: String) {
|
func handleJob(sessionKey: String, state: String) {
|
||||||
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
|
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
|
||||||
if isStart {
|
if isStart {
|
||||||
@@ -131,17 +129,6 @@ final class WorkActivityStore {
|
|||||||
self.refreshDerivedState()
|
self.refreshDerivedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMainSessionKey(_ sessionKey: String) {
|
|
||||||
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
guard trimmed != self.mainSessionKeyStorage else { return }
|
|
||||||
self.mainSessionKeyStorage = trimmed
|
|
||||||
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
|
|
||||||
self.pickNextSession()
|
|
||||||
}
|
|
||||||
self.refreshDerivedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearJob(sessionKey: String) {
|
private func clearJob(sessionKey: String) {
|
||||||
guard self.jobs[sessionKey] != nil else { return }
|
guard self.jobs[sessionKey] != nil else { return }
|
||||||
self.jobs.removeValue(forKey: sessionKey)
|
self.jobs.removeValue(forKey: sessionKey)
|
||||||
@@ -164,8 +151,8 @@ final class WorkActivityStore {
|
|||||||
|
|
||||||
private func pickNextSession() {
|
private func pickNextSession() {
|
||||||
// Prefer main if present.
|
// Prefer main if present.
|
||||||
if self.isActive(sessionKey: self.mainSessionKeyStorage) {
|
if self.isActive(sessionKey: self.mainSessionKey) {
|
||||||
self.currentSessionKey = self.mainSessionKeyStorage
|
self.currentSessionKey = self.mainSessionKey
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +163,7 @@ final class WorkActivityStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func role(for sessionKey: String) -> SessionRole {
|
private func role(for sessionKey: String) -> SessionRole {
|
||||||
sessionKey == self.mainSessionKeyStorage ? .main : .other
|
sessionKey == self.mainSessionKey ? .main : .other
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isActive(sessionKey: String) -> Bool {
|
private func isActive(sessionKey: String) -> Bool {
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
public let uptimems: Int
|
public let uptimems: Int
|
||||||
public let configpath: String?
|
public let configpath: String?
|
||||||
public let statedir: String?
|
public let statedir: String?
|
||||||
public let sessiondefaults: [String: AnyCodable]?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
presence: [PresenceEntry],
|
presence: [PresenceEntry],
|
||||||
@@ -260,8 +259,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
stateversion: StateVersion,
|
stateversion: StateVersion,
|
||||||
uptimems: Int,
|
uptimems: Int,
|
||||||
configpath: String?,
|
configpath: String?,
|
||||||
statedir: String?,
|
statedir: String?
|
||||||
sessiondefaults: [String: AnyCodable]?
|
|
||||||
) {
|
) {
|
||||||
self.presence = presence
|
self.presence = presence
|
||||||
self.health = health
|
self.health = health
|
||||||
@@ -269,7 +267,6 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
self.uptimems = uptimems
|
self.uptimems = uptimems
|
||||||
self.configpath = configpath
|
self.configpath = configpath
|
||||||
self.statedir = statedir
|
self.statedir = statedir
|
||||||
self.sessiondefaults = sessiondefaults
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case presence
|
case presence
|
||||||
@@ -278,7 +275,6 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
case uptimems = "uptimeMs"
|
case uptimems = "uptimeMs"
|
||||||
case configpath = "configPath"
|
case configpath = "configPath"
|
||||||
case statedir = "stateDir"
|
case statedir = "stateDir"
|
||||||
case sessiondefaults = "sessionDefaults"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +341,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let provider: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
@@ -354,7 +350,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
provider: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
@@ -362,7 +358,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.provider = provider
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
@@ -371,7 +367,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case provider
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
@@ -383,7 +379,7 @@ public struct PollParams: Codable, Sendable {
|
|||||||
public let options: [String]
|
public let options: [String]
|
||||||
public let maxselections: Int?
|
public let maxselections: Int?
|
||||||
public let durationhours: Int?
|
public let durationhours: Int?
|
||||||
public let channel: String?
|
public let provider: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
@@ -393,7 +389,7 @@ public struct PollParams: Codable, Sendable {
|
|||||||
options: [String],
|
options: [String],
|
||||||
maxselections: Int?,
|
maxselections: Int?,
|
||||||
durationhours: Int?,
|
durationhours: Int?,
|
||||||
channel: String?,
|
provider: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
@@ -402,7 +398,7 @@ public struct PollParams: Codable, Sendable {
|
|||||||
self.options = options
|
self.options = options
|
||||||
self.maxselections = maxselections
|
self.maxselections = maxselections
|
||||||
self.durationhours = durationhours
|
self.durationhours = durationhours
|
||||||
self.channel = channel
|
self.provider = provider
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
@@ -412,7 +408,7 @@ public struct PollParams: Codable, Sendable {
|
|||||||
case options
|
case options
|
||||||
case maxselections = "maxSelections"
|
case maxselections = "maxSelections"
|
||||||
case durationhours = "durationHours"
|
case durationhours = "durationHours"
|
||||||
case channel
|
case provider
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
@@ -426,7 +422,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let thinking: String?
|
public let thinking: String?
|
||||||
public let deliver: Bool?
|
public let deliver: Bool?
|
||||||
public let attachments: [AnyCodable]?
|
public let attachments: [AnyCodable]?
|
||||||
public let channel: String?
|
public let provider: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -442,7 +438,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
thinking: String?,
|
thinking: String?,
|
||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
attachments: [AnyCodable]?,
|
attachments: [AnyCodable]?,
|
||||||
channel: String?,
|
provider: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -457,7 +453,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.thinking = thinking
|
self.thinking = thinking
|
||||||
self.deliver = deliver
|
self.deliver = deliver
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
self.channel = channel
|
self.provider = provider
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -473,7 +469,7 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case thinking
|
case thinking
|
||||||
case deliver
|
case deliver
|
||||||
case attachments
|
case attachments
|
||||||
case channel
|
case provider
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -849,67 +845,42 @@ public struct ConfigGetParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct ConfigSetParams: Codable, Sendable {
|
public struct ConfigSetParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String
|
||||||
basehash: String?
|
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigApplyParams: Codable, Sendable {
|
public struct ConfigApplyParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
|
||||||
public let sessionkey: String?
|
public let sessionkey: String?
|
||||||
public let note: String?
|
public let note: String?
|
||||||
public let restartdelayms: Int?
|
public let restartdelayms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?,
|
|
||||||
sessionkey: String?,
|
sessionkey: String?,
|
||||||
note: String?,
|
note: String?,
|
||||||
restartdelayms: Int?
|
restartdelayms: Int?
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
|
||||||
self.sessionkey = sessionkey
|
self.sessionkey = sessionkey
|
||||||
self.note = note
|
self.note = note
|
||||||
self.restartdelayms = restartdelayms
|
self.restartdelayms = restartdelayms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
|
||||||
case sessionkey = "sessionKey"
|
case sessionkey = "sessionKey"
|
||||||
case note
|
case note
|
||||||
case restartdelayms = "restartDelayMs"
|
case restartdelayms = "restartDelayMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigPatchParams: Codable, Sendable {
|
|
||||||
public let raw: String
|
|
||||||
public let basehash: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
raw: String,
|
|
||||||
basehash: String?
|
|
||||||
) {
|
|
||||||
self.raw = raw
|
|
||||||
self.basehash = basehash
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case raw
|
|
||||||
case basehash = "baseHash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ConfigSchemaParams: Codable, Sendable {
|
public struct ConfigSchemaParams: Codable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1102,7 @@ public struct TalkModeParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChannelsStatusParams: Codable, Sendable {
|
public struct ProvidersStatusParams: Codable, Sendable {
|
||||||
public let probe: Bool?
|
public let probe: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
@@ -1148,52 +1119,52 @@ public struct ChannelsStatusParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChannelsStatusResult: Codable, Sendable {
|
public struct ProvidersStatusResult: Codable, Sendable {
|
||||||
public let ts: Int
|
public let ts: Int
|
||||||
public let channelorder: [String]
|
public let providerorder: [String]
|
||||||
public let channellabels: [String: AnyCodable]
|
public let providerlabels: [String: AnyCodable]
|
||||||
public let channels: [String: AnyCodable]
|
public let providers: [String: AnyCodable]
|
||||||
public let channelaccounts: [String: AnyCodable]
|
public let provideraccounts: [String: AnyCodable]
|
||||||
public let channeldefaultaccountid: [String: AnyCodable]
|
public let providerdefaultaccountid: [String: AnyCodable]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ts: Int,
|
ts: Int,
|
||||||
channelorder: [String],
|
providerorder: [String],
|
||||||
channellabels: [String: AnyCodable],
|
providerlabels: [String: AnyCodable],
|
||||||
channels: [String: AnyCodable],
|
providers: [String: AnyCodable],
|
||||||
channelaccounts: [String: AnyCodable],
|
provideraccounts: [String: AnyCodable],
|
||||||
channeldefaultaccountid: [String: AnyCodable]
|
providerdefaultaccountid: [String: AnyCodable]
|
||||||
) {
|
) {
|
||||||
self.ts = ts
|
self.ts = ts
|
||||||
self.channelorder = channelorder
|
self.providerorder = providerorder
|
||||||
self.channellabels = channellabels
|
self.providerlabels = providerlabels
|
||||||
self.channels = channels
|
self.providers = providers
|
||||||
self.channelaccounts = channelaccounts
|
self.provideraccounts = provideraccounts
|
||||||
self.channeldefaultaccountid = channeldefaultaccountid
|
self.providerdefaultaccountid = providerdefaultaccountid
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case ts
|
case ts
|
||||||
case channelorder = "channelOrder"
|
case providerorder = "providerOrder"
|
||||||
case channellabels = "channelLabels"
|
case providerlabels = "providerLabels"
|
||||||
case channels
|
case providers
|
||||||
case channelaccounts = "channelAccounts"
|
case provideraccounts = "providerAccounts"
|
||||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
case providerdefaultaccountid = "providerDefaultAccountId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChannelsLogoutParams: Codable, Sendable {
|
public struct ProvidersLogoutParams: Codable, Sendable {
|
||||||
public let channel: String
|
public let provider: String
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
channel: String,
|
provider: String,
|
||||||
accountid: String?
|
accountid: String?
|
||||||
) {
|
) {
|
||||||
self.channel = channel
|
self.provider = provider
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channel
|
case provider
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1679,27 +1650,6 @@ public struct ChatAbortParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatInjectParams: Codable, Sendable {
|
|
||||||
public let sessionkey: String
|
|
||||||
public let message: String
|
|
||||||
public let label: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
sessionkey: String,
|
|
||||||
message: String,
|
|
||||||
label: String?
|
|
||||||
) {
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
self.message = message
|
|
||||||
self.label = label
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
case message
|
|
||||||
case label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ChatEvent: Codable, Sendable {
|
public struct ChatEvent: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ClawdbotProtocol
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
@@ -8,16 +7,16 @@ import Testing
|
|||||||
struct ConnectionsSettingsSmokeTests {
|
struct ConnectionsSettingsSmokeTests {
|
||||||
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||||
let store = ConnectionsStore(isPreview: true)
|
let store = ConnectionsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ProvidersStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
channelLabels: [
|
providerLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
channels: [
|
providers: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": AnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"linked": true,
|
"linked": true,
|
||||||
@@ -73,8 +72,8 @@ struct ConnectionsSettingsSmokeTests {
|
|||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
channelAccounts: [:],
|
providerAccounts: [:],
|
||||||
channelDefaultAccountId: [
|
providerDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
@@ -98,16 +97,16 @@ struct ConnectionsSettingsSmokeTests {
|
|||||||
|
|
||||||
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||||
let store = ConnectionsStore(isPreview: true)
|
let store = ConnectionsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ProvidersStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
channelLabels: [
|
providerLabels: [
|
||||||
"whatsapp": "WhatsApp",
|
"whatsapp": "WhatsApp",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
channels: [
|
providers: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": AnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"linked": false,
|
"linked": false,
|
||||||
@@ -149,8 +148,8 @@ struct ConnectionsSettingsSmokeTests {
|
|||||||
"lastProbeAt": 1_700_000_200_000,
|
"lastProbeAt": 1_700_000_200_000,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
channelAccounts: [:],
|
providerAccounts: [:],
|
||||||
channelDefaultAccountId: [
|
providerDefaultAccountId: [
|
||||||
"whatsapp": "default",
|
"whatsapp": "default",
|
||||||
"telegram": "default",
|
"telegram": "default",
|
||||||
"signal": "default",
|
"signal": "default",
|
||||||
|
|||||||
Loaded 100 of 2333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user