diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd33..0e11b50e1c83 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -40,7 +40,7 @@ Treat the overall testing or implementation loop—not an assistant turn or one - Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. - Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. - On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). ## Authenticate the browser on the first navigation @@ -50,24 +50,13 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. -```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test -``` - -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. - -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. ## Inspect or seed SQLite state diff --git a/.command-center-public-baseline b/.command-center-public-baseline index 86132e403b6b..fb725c2b91bd 100644 --- a/.command-center-public-baseline +++ b/.command-center-public-baseline @@ -1 +1 @@ -b511227b7ad421c422f1ebca65116776020e4799 +239ef1c54df2f657912ccb5b8e25193d49d90417 diff --git a/.env.example b/.env.example index 61cdd66d246a..fc67dcef9478 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 000000000000..94a02b7806dc --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 000000000000..4935864e46f0 --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a49fc6be627..6fe424479653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,8 +115,29 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6f49d9f239e..f33f15857c0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -682,6 +682,8 @@ jobs: if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_mode == 'validate' && needs.build.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v6 @@ -872,7 +874,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -890,11 +892,11 @@ jobs: release-assets/*.yml release-assets/SHA256SUMS.txt fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -911,7 +913,7 @@ jobs: release-assets/*.yml release-assets/SHA256SUMS.txt fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} deploy_web: name: Deploy hosted web app diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 000000000000..23eec72923bd --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/AGENTS.md b/AGENTS.md index c3a7fe92bf40..1b41f833ce58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 3e748d11734a..919ffd8cad58 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; @@ -17,7 +18,9 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -220,20 +223,49 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* shellEnvironment.installIntoProcess; + const hasCommandLinePasswordStore = + preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; + const linuxElectronOptions = + environment.platform === "linux" && !hasCommandLinePasswordStore + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + : preReadyElectronOptions.linux; + if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { + if ( + linuxElectronOptions.passwordStore !== null || + preReadyElectronOptions.linux?.passwordStore !== null + ) { + yield* electronApp.removeCommandLineSwitch("password-store"); + } + if (linuxElectronOptions.passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch( + "password-store", + linuxElectronOptions.passwordStore, + ); + } + } const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + if (linuxElectronOptions !== null) { + yield* logStartupInfo("linux password store configured", { + passwordStore: hasCommandLinePasswordStore + ? "command-line" + : (linuxElectronOptions.passwordStore ?? "electron-default"), + xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + }); } yield* appIdentity.configure; @@ -245,9 +277,16 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + if (environment.platform === "linux") { + const selectedBackend = yield* safeStorage.selectedStorageBackend; + yield* logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + }); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 49a6e6b7ac65..e41f054e4e13 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index bed3378abeaf..2035c71d6605 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly>): Record return decoded.slice("encrypted:".length); }); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts new file mode 100644 index 000000000000..8d892550fe12 --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary. +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; + +import { + resolveEarlyLinuxElectronOptions, + resolveEarlyLinuxPasswordStorePreference, +} from "./DesktopEarlyElectronStartup.ts"; + +describe("DesktopEarlyElectronStartup", () => { + const joinPath = NodePath.posix.join; + + it("reads the persisted linux password-store preference before Electron is ready", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/tmp/command-center-user/.t3-test" }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/tmp/command-center-user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("accepts JSONC in the early desktop settings file", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/tmp/command-center-user/.t3-test" }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: () => `{ + // manually edited setting + "linuxPasswordStore": "gnome-libsecret", + }`, + }); + + assert.equal(preference, "gnome-libsecret"); + }); + + it("falls back to auto when the early settings document is missing or invalid", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: {}, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: () => { + throw new Error("missing"); + }, + }); + + assert.equal(preference, "auto"); + }); + + it("preserves absolute root paths when resolving early settings", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/" }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("resolves the early linux Electron switches", () => { + const options = resolveEarlyLinuxElectronOptions({ + env: { + T3CODE_HOME: "/tmp/command-center-user/.t3-test", + XDG_CURRENT_DESKTOP: "niri", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/tmp/command-center-user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "auto" }); + }, + }); + + assert.deepEqual(options, { + linuxWmClass: "commandcenter-dev", + passwordStore: "gnome-libsecret", + }); + }); + + it("keeps implicit development state under ~/.command-center/dev when home overrides are unset", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/tmp/command-center-user/.command-center/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet" }); + }, + }); + + assert.equal(preference, "kwallet"); + }); + + it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + T3CODE_HOME: " ", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/tmp/command-center-user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/tmp/command-center-user/.command-center/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); + }, + }); + + assert.equal(preference, "gnome-libsecret"); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts new file mode 100644 index 000000000000..7c34ad3d8e18 --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -0,0 +1,93 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + type LinuxPasswordStoreSwitch, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; +import { + resolveDesktopBaseDir, + resolveDesktopStateDir, + type JoinPath, +} from "./DesktopStatePaths.ts"; + +interface EarlyDesktopSettingsInput { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly readFileString: (path: string) => string; +} + +type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput; + +export interface EarlyLinuxElectronOptions { + readonly linuxWmClass: string; + readonly passwordStore: LinuxPasswordStoreSwitch | null; +} + +const trimNonEmpty = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + +const EarlyDesktopSettingsJson = fromLenientJson( + Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), + }), +); +const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson); + +const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => + trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; + +function resolveEarlyDesktopSettingsPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; +}): string { + const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); + const commandCenterHome = Option.fromUndefinedOr(input.env.COMMAND_CENTER_HOME); + const baseDir = resolveDesktopBaseDir({ + homeDirectory: input.homeDirectory, + joinPath: input.joinPath, + t3Home, + commandCenterHome, + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: isDevelopmentEnvironment(input.env), + joinPath: input.joinPath, + t3Home, + commandCenterHome, + }); + return input.joinPath(stateDir, "desktop-settings.json"); +} + +export function resolveEarlyLinuxPasswordStorePreference( + input: EarlyDesktopSettingsInput, +): LinuxPasswordStorePreference { + const settingsPath = resolveEarlyDesktopSettingsPath(input); + try { + const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath)); + return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore); + } catch { + return DEFAULT_LINUX_PASSWORD_STORE; + } +} + +export function resolveEarlyLinuxElectronOptions( + input: EarlyLinuxElectronOptionsInput, +): EarlyLinuxElectronOptions { + const preference = resolveEarlyLinuxPasswordStorePreference(input); + return { + linuxWmClass: isDevelopmentEnvironment(input.env) ? "commandcenter-dev" : "command-center", + passwordStore: resolveLinuxPasswordStoreSwitch({ + preference, + env: input.env, + }), + }; +} diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index aab42a71c62e..61f966487d18 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service< readonly appUserModelId: string; readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; + readonly linuxApplicationsDir: string; + readonly appImagePath: Option.Option; readonly userDataDirName: string; readonly legacyUserDataDirNames: readonly string[]; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; @@ -168,6 +170,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( const legacyUserDataDirNames = isDevelopment ? ["t3code-dev", "T3 Code (Dev)"] : ["t3code", "T3 Code (Alpha)", "Command Center (Alpha)"]; + const linuxApplicationsDir = path.join( + Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), + "applications", + ); const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -211,6 +217,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( ), linuxDesktopEntryName: isDevelopment ? "commandcenter-dev.desktop" : "command-center.desktop", linuxWmClass: isDevelopment ? "commandcenter-dev" : "command-center", + linuxApplicationsDir, + appImagePath: config.appImagePath, userDataDirName, legacyUserDataDirNames, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 978e000a7f58..be9d7f3451f1 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: (listener) => Effect.acquireRelease( Effect.sync(() => { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts new file mode 100644 index 000000000000..711dee47575e --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -0,0 +1,233 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; + +interface RecordedRegistration { + readonly directories: string[]; + readonly files: Array<{ readonly path: string; readonly content: string }>; + readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; +} + +const makeEnvironment = (overrides: Record = {}) => + DesktopEnvironment.DesktopEnvironment.of({ + platform: "linux", + isPackaged: true, + isDevelopment: false, + displayName: "Command Center (Alpha)", + linuxWmClass: "commandcenter", + linuxApplicationsDir: "/tmp/command-center-alice/.local/share/applications", + appImagePath: Option.some("/tmp/command-center-alice/Applications/T3-Code.AppImage"), + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + ...overrides, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +const mockProcess = (exitCode: number) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeHandlerLayer = ( + recorded: RecordedRegistration, + input: { + readonly environment?: Record; + readonly xdgMimeExitCode?: number; + readonly writeError?: PlatformError.PlatformError; + } = {}, +) => + DesktopLinuxUrlHandler.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + recorded.directories.push(path); + }), + writeFileString: (path, content) => + input.writeError + ? Effect.fail(input.writeError) + : Effect.sync(() => { + recorded.files.push({ path, content }); + }), + }), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + recorded.commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + }), + ), + ), + ), + ); + +const runRegister = ( + recorded: RecordedRegistration, + input: Parameters[1] = {}, +) => + Effect.gen(function* () { + const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + yield* handler.register; + }).pipe(Effect.provide(makeHandlerLayer(recorded, input))); + +const emptyRecording = (): RecordedRegistration => ({ + directories: [], + files: [], + commands: [], +}); + +describe("DesktopLinuxUrlHandler", () => { + it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "Command Center (Nightly)", + execTarget: '/tmp/al ice/Apps/Command Center "100%" $HOME\\x.AppImage', + scheme: "commandcenter", + }); + + assert.include(entry, "[Desktop Entry]"); + assert.include(entry, "Name=Command Center (Nightly)"); + // Exec composes both escaping layers: a literal backslash becomes four + // backslashes in the file, a quote three characters, a dollar sign two + // backslashes plus the sign. + assert.include( + entry, + 'Exec="/tmp/al ice/Apps/Command Center \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + ); + assert.include(entry, "NoDisplay=true"); + assert.notInclude(entry, "StartupWMClass="); + assert.include(entry, "MimeType=x-scheme-handler/commandcenter;"); + }); + + it("carries structured context on registration errors", () => { + const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme: "commandcenter", + desktopEntryPath: + "/tmp/command-center-alice/.local/share/applications/commandcenter-url-handler.desktop", + cause: new Error("boom"), + }); + assert.equal( + writeError.message, + "Failed to register the commandcenter:// URL handler (step: write-desktop-entry).", + ); + assert.equal( + writeError.desktopEntryPath, + "/tmp/command-center-alice/.local/share/applications/commandcenter-url-handler.desktop", + ); + + const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme: "commandcenter", + exitCode: 4, + }); + assert.equal( + exitError.message, + "Failed to register the commandcenter:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + ); + }); + + it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded); + + assert.deepEqual(recorded.directories, [ + "/tmp/command-center-alice/.local/share/applications", + ]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/tmp/command-center-alice/.local/share/applications/commandcenter-url-handler.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/tmp/command-center-alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/commandcenter;"); + assert.deepEqual(recorded.commands, [ + { + command: "xdg-mime", + args: ["default", "commandcenter-url-handler.desktop", "x-scheme-handler/commandcenter"], + }, + ]); + }); + }); + + it.effect("falls back to the process executable outside an AppImage", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { environment: { appImagePath: Option.none() } }); + + assert.include( + recorded.files[0]?.content, + `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + ); + }); + }); + + it.effect("does nothing on other platforms or unpackaged builds", () => { + const nonLinux = emptyRecording(); + const unpackaged = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); + yield* runRegister(unpackaged, { environment: { isPackaged: false } }); + + for (const recorded of [nonLinux, unpackaged]) { + assert.deepEqual(recorded.directories, []); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.commands, []); + } + }); + }); + + it.effect("never fails startup when registration cannot complete", () => { + const xdgMimeFailed = emptyRecording(); + const writeFailed = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); + yield* runRegister(writeFailed, { + writeError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + description: "read-only filesystem", + pathOrDescriptor: + "/tmp/command-center-alice/.local/share/applications/commandcenter-url-handler.desktop", + }), + }); + + assert.equal(xdgMimeFailed.files.length, 1); + assert.deepEqual(writeFailed.commands, []); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts new file mode 100644 index 000000000000..ff7d677cc12c --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -0,0 +1,191 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// Linux ships as an AppImage, so the .desktop entry users end up with is +// created by whatever integration tool they use (AppImageLauncher names it +// appimagekit_-….desktop) and its filename is not under our control. +// Electron's app.setAsDefaultProtocolClient resolves the desktop id from +// setDesktopName, which cannot match those files — so the browser keeps +// prompting "Choose an application" for every OAuth callback. Instead, write +// our own handler entry pointing at the current AppImage and claim the +// scheme default via xdg-mime, exactly what the file manager's "set as +// default" checkbox would record in mimeapps.list. +export const URL_HANDLER_DESKTOP_ENTRY_NAME = "commandcenter-url-handler.desktop"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); + +export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorClass()( + "DesktopLinuxUrlHandlerRegistrationError", + { + step: Schema.Literals(["write-desktop-entry", "set-default-handler"]), + scheme: Schema.String, + desktopEntryPath: Schema.optionalKey(Schema.String), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`; + return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`; + } +} + +const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); + +const escapeDesktopEntryString = (value: string): string => + value + .replaceAll("\\", "\\\\") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + +// Exec values are unescaped twice by implementations: first the general +// string-value rules, then the Exec quoting rules — so writing composes the +// layers in reverse. The argument is double-quoted with reserved characters +// backslash-escaped and literal percent signs doubled (field codes), and the +// general string escaping is applied on top: a literal backslash ends up as +// four backslashes in the file, a quote as \\", a dollar sign as \\$. +export function escapeDesktopEntryExecArgument(value: string): string { + const quoted = value + .replaceAll("\\", () => "\\\\") + .replaceAll("`", () => "\\`") + .replaceAll("$", () => "\\$") + .replaceAll('"', () => '\\"') + .replaceAll("%", () => "%%"); + return escapeDesktopEntryString(`"${quoted}"`); +} + +// The AppImage integration entry owns the window identity and icon. This +// hidden URL-only entry must not compete with it for StartupWMClass matching. +export function renderUrlHandlerDesktopEntry(input: { + readonly displayName: string; + readonly execTarget: string; + readonly scheme: string; +}): string { + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${escapeDesktopEntryString(input.displayName)}`, + `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + "Terminal=false", + "NoDisplay=true", + "StartupNotify=false", + `MimeType=x-scheme-handler/${input.scheme};`, + "", + ].join("\n"); +} + +export class DesktopLinuxUrlHandler extends Context.Service< + DesktopLinuxUrlHandler, + { + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const desktopEntryPath = environment.path.join( + environment.linuxApplicationsDir, + URL_HANDLER_DESKTOP_ENTRY_NAME, + ); + + const writeDesktopEntry = Effect.gen(function* () { + // Inside the mounted AppImage, process.execPath points at a transient + // /tmp/.mount_* path — the handler must launch the AppImage itself. + const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); + yield* fileSystem.writeFileString( + desktopEntryPath, + renderUrlHandlerDesktopEntry({ + displayName: environment.displayName, + execTarget, + scheme, + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme, + desktopEntryPath, + cause, + }), + ), + ); + + const setDefaultHandler = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "xdg-mime", + ["default", URL_HANDLER_DESKTOP_ENTRY_NAME, `x-scheme-handler/${scheme}`], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode; + if ((exitCode as unknown as number) !== 0) { + return yield* new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + exitCode: Number(exitCode), + }); + } + }), + ).pipe( + Effect.mapError((error) => + isRegistrationError(error) + ? error + : new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + cause: error, + }), + ), + ); + + const register = Effect.gen(function* () { + if (environment.platform !== "linux" || !environment.isPackaged) { + return; + } + yield* writeDesktopEntry; + yield* setDefaultHandler; + yield* logInfo("registered URL scheme handler", { scheme }); + }).pipe( + // Registration is best-effort: a missing xdg-mime or read-only home must + // never block startup — the OS chooser remains as fallback. + Effect.catch((error) => + logWarning("URL scheme handler registration failed", { + scheme, + step: error.step, + message: error.message, + ...(error.desktopEntryPath === undefined + ? {} + : { desktopEntryPath: error.desktopEntryPath }), + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + Effect.withSpan("desktop.linuxUrlHandler.register"), + ); + + return DesktopLinuxUrlHandler.of({ register }); +}); + +export const layer = Layer.effect(DesktopLinuxUrlHandler, make); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts new file mode 100644 index 000000000000..a29e0fd3baf6 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -0,0 +1,130 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( + () => ({ + appendSwitchMock: vi.fn(), + getSwitchValueMock: vi.fn(), + hasSwitchMock: vi.fn(), + registerSchemesMock: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + getSwitchValue: getSwitchValueMock, + hasSwitch: hasSwitchMock, + }, + }, + protocol: { + registerSchemesAsPrivileged: registerSchemesMock, + }, +})); + +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; + +describe("DesktopPreReadyPlatform", () => { + beforeEach(() => { + appendSwitchMock.mockReset(); + getSwitchValueMock.mockReset(); + hasSwitchMock.mockReset(); + registerSchemesMock.mockReset(); + }); + + it("reads an explicit Electron command-line switch value", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: (switchName) => switchName === "password-store", + getSwitchValue: (switchName) => { + assert.equal(switchName, "password-store"); + return "basic"; + }, + }, + "password-store", + ); + + assert.equal(value, "basic"); + }); + + it("treats valueless Electron command-line switches as absent", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => true, + getSwitchValue: () => "", + }, + "password-store", + ); + + assert.isNull(value); + }); + + it("returns null for missing Electron command-line switches", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => false, + getSwitchValue: () => { + throw new Error("Unexpected switch value read."); + }, + }, + "password-store", + ); + + assert.isNull(value); + }); + + it.effect( + "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", + () => + Effect.gen(function* () { + class ClerkShaped extends Context.Service()( + "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped", + ) {} + + const events: Array = []; + registerSchemesMock.mockImplementation(() => { + events.push("pre-ready"); + }); + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + ); + + const clerkShapedLayer = Layer.effect( + ClerkShaped, + Effect.promise(() => Promise.resolve()).pipe( + Effect.map(() => { + events.push("clerk"); + return { ready: true as const }; + }), + ), + ); + + const runtimeLayer = clerkShapedLayer.pipe( + Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), + Layer.provideMerge(preReadyLayer), + ); + + const result = yield* Effect.all({ + clerk: ClerkShaped, + preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, + }).pipe(Effect.provide(runtimeLayer)); + + assert.deepEqual(result, { + clerk: { ready: true }, + preReady: { + linux: null, + linuxPasswordStoreCommandLine: null, + }, + }); + assert.deepEqual(events, ["pre-ready", "clerk"]); + assert.equal(registerSchemesMock.mock.calls.length, 1); + assert.equal(appendSwitchMock.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts new file mode 100644 index 000000000000..7d145632d0bb --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -0,0 +1,74 @@ +// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; + +export interface DesktopPreReadyCommandLineReader { + readonly hasSwitch: (switchName: string) => boolean; + readonly getSwitchValue: (switchName: string) => string; +} + +export function readCommandLineSwitchValue( + commandLine: DesktopPreReadyCommandLineReader, + switchName: string, +): string | null { + if (!commandLine.hasSwitch(switchName)) { + return null; + } + + const value = commandLine.getSwitchValue(switchName).trim(); + return value.length > 0 ? value : null; +} + +export const resolveEarlyLinuxElectronOptionsFromProcess = + (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory: NodeOS.homedir(), + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); + +export class DesktopPreReadyElectronOptions extends Context.Service< + DesktopPreReadyElectronOptions, + { + readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null; + readonly linuxPasswordStoreCommandLine: string | null; + } +>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { + const linuxPasswordStoreCommandLine = + platform === "linux" + ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") + : null; + const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + + if (linux !== null) { + Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); + if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + } + + return { linux, linuxPasswordStoreCommandLine }; + }); +}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); + +// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot +// observe app readiness before scheme privileges and command-line switches exist. +export const layer = Layer.mergeAll( + ElectronProtocol.layerSchemePrivileges, + Layer.effect(DesktopPreReadyElectronOptions, make), +); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts new file mode 100644 index 000000000000..8982ac7ec85d --- /dev/null +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -0,0 +1,42 @@ +import * as Option from "effect/Option"; + +export type JoinPath = (first: string, ...segments: string[]) => string; + +function normalizeConfiguredBaseDir(configuredHome: Option.Option): Option.Option { + if (Option.isNone(configuredHome)) { + return Option.none(); + } + const trimmed = configuredHome.value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +} + +export function resolveDesktopBaseDir(input: { + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; + readonly commandCenterHome?: Option.Option | undefined; +}): string { + const configuredHome = Option.orElse( + input.commandCenterHome ?? Option.none(), + () => input.t3Home, + ); + return Option.getOrElse(normalizeConfiguredBaseDir(configuredHome), () => + input.joinPath(input.homeDirectory, ".command-center"), + ); +} + +export function resolveDesktopStateDir(input: { + readonly baseDir: string; + readonly isDevelopment: boolean; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; + readonly commandCenterHome?: Option.Option | undefined; +}): string { + const configuredHome = Option.orElse( + input.commandCenterHome ?? Option.none(), + () => input.t3Home, + ); + const useDevSubdir = + input.isDevelopment && Option.isNone(normalizeConfiguredBaseDir(configuredHome)); + return input.joinPath(input.baseDir, useDevSubdir ? "dev" : "userdata"); +} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 31fcc1bfcba7..a5606eba0bd3 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,6 +14,7 @@ const { quitMock, relaunchMock, removeListenerMock, + removeSwitchMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -34,6 +35,7 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), + removeSwitchMock: vi.fn(), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -52,6 +54,7 @@ vi.mock("electron", () => ({ app: { commandLine: { appendSwitch: appendSwitchMock, + removeSwitch: removeSwitchMock, }, dock: { setIcon: setDockIconMock, @@ -89,6 +92,7 @@ describe("ElectronApp", () => { quitMock.mockClear(); relaunchMock.mockClear(); removeListenerMock.mockClear(); + removeSwitchMock.mockClear(); setPathMock.mockClear(); }); @@ -178,4 +182,13 @@ describe("ElectronApp", () => { ]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("removes command-line switches through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.removeCommandLineSwitch("password-store"); + + assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 73323617195d..6fb84c53b367 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -69,6 +69,7 @@ export class ElectronApp extends Context.Service< readonly onBeforeQuitForUpdate: ( listener: () => void, ) => Effect.Effect; + readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -191,6 +192,10 @@ export const make = ElectronApp.of({ Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), ).pipe(Effect.asVoid), + removeCommandLineSwitch: (switchName) => + Effect.sync(() => { + Electron.app.commandLine.removeSwitch(switchName); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea8..f1add4c7cc7b 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -23,6 +23,21 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFilesError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + defaultPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`; + } +} + export class ElectronDialogConfirmError extends Schema.TaggedErrorClass()( "ElectronDialogConfirmError", { @@ -69,6 +84,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } +export interface ElectronDialogPickFilesInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; + readonly filters: readonly Electron.FileFilter[]; +} + export interface ElectronDialogConfirmInput { readonly owner: Option.Option; readonly message: string; @@ -92,6 +114,9 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; + readonly pickFiles: ( + input: ElectronDialogPickFilesInput, + ) => Effect.Effect; readonly confirm: ( input: ElectronDialogConfirmInput, ) => Effect.Effect; @@ -137,6 +162,32 @@ export const make = ElectronDialog.of({ } return Option.fromNullishOr(result.filePaths[0]); }), + pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = { + properties: ["openFile", "multiSelections"], + filters: [...input.filters], + ...(defaultPath === null ? {} : { defaultPath }), + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFilesError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + return result.canceled ? [] : result.filePaths; + }), confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { const normalizedMessage = input.message.trim(); if (normalizedMessage.length === 0) { diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 526cb4291750..d5270da9c12d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -154,6 +154,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +/** + * Must run synchronously during process bootstrap, before Electron emits `ready`. + */ +export function registerDesktopSchemePrivilegesSync(): void { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +} + +const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( + Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"), +); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + async function proxyRequest( request: Request, targetOrigin: URL, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 76162c1647a0..b9dab7105fb8 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service< readonly decryptString: ( value: Uint8Array, ) => Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -export const make = ElectronSafeStorage.of({ - isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), - }), - encryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - decryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), + selectedStorageBackend: Effect.sync(() => { + if (platform !== "linux") { + return Option.none(); + } + try { + return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } }), + }); }); -export const layer = Layer.succeed(ElectronSafeStorage, make); +export const layer = Layer.effect(ElectronSafeStorage, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6effd..503a586d9c5b 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getWindowFullscreenState, openExternal, pickFolder, + pickThemeFiles, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -79,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickThemeFiles); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f96..4d8e783d1221 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 4d50ad8d665e..febdefa9825b 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,7 +16,7 @@ import { DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, - PreviewAnnotationPayloadSchema, + PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ export const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: Schema.NullOr(PreviewAnnotationPayloadSchema), + result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; return yield* manager.pickElement(tabId); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabadd..cfa854e7a16a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,10 +3,15 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap, + type PickedThemeFile, } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +/** Theme files are a few KB; anything larger returns empty text and lets the + * renderer reject it by size without the contents ever crossing the bridge. */ +const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; + +export const pickThemeFiles = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_THEME_FILES_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)), + handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The VS Code extensions directory is the same dotfolder on Windows, + // macOS, and Linux; when it is missing the picker opens wherever the + // platform would by default. + const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const defaultPath = yield* fileSystem + .exists(extensionsDir) + .pipe(Effect.orElseSucceed(() => false)); + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (paths.length === 0) { + return null; + } + return yield* Effect.forEach(paths, (filePath) => { + const name = path.basename(filePath); + return Effect.gen(function* () { + const info = yield* fileSystem.stat(filePath); + const size = Number(info.size); + if (size > PICKED_THEME_FILE_MAX_BYTES) { + return { name, size, text: "" } satisfies PickedThemeFile; + } + const text = yield* fileSystem.readFileString(filePath); + return { name, size, text } satisfies PickedThemeFile; + }).pipe( + // An unreadable file degrades to an entry the renderer reports. + Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), + ); + }); + }), +}); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts new file mode 100644 index 000000000000..a91790200771 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + resolveLinuxSecretStorageUnavailableMessage, +} from "./linuxSecretStorage.ts"; + +const autoSwitch = (env: NodeJS.ProcessEnv) => + resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); + +describe("linuxSecretStorage", () => { + it("preserves explicit supported password-store preferences", () => { + expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); + expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); + expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); + expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); + }); + + it("falls back to auto for missing or unsupported preferences", () => { + expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); + expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); + }); + + it("uses explicit preferences instead of the auto heuristic", () => { + for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), + ).toBe(preference); + // An explicit preference also wins where auto would have stayed out of the way. + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), + ).toBe(preference); + } + }); + + it("leaves canonical KDE sessions to Electron's own wallet selection", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "5" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); + }); + + it("does not force a password-store for desktops Electron already recognizes", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); + for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); + } + }); + + it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); + }); + + it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe("gnome-libsecret"); + expect(autoSwitch({})).toBe("gnome-libsecret"); + }); + + it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); + // Chromium stops at the first recognized value, so a later name cannot rescue the session. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); + }); + + it("does not treat lowercase desktop names as ones Electron recognizes", () => { + // Chromium matches these case-sensitively, so lowercase spellings reach basic text. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("overrides KDE sessions identified only by legacy variables", () => { + // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and + // basic text for the rest. Either way these are the variables a previous session leaves behind, + // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); + for (const session of [ + "kde", + "kde-plasma", + "kde4", + "plasma", + "plasmawayland", + "plasmawayland-dev", + "plasmax11", + "plasmax11-dev", + ]) { + expect(autoSwitch({ DESKTOP_SESSION: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_SESSION_DESKTOP: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ GDMSESSION: session })).toBe("gnome-libsecret"); + } + expect(autoSwitch({ DESKTOP_SESSION: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ KDE_SESSION_VERSION: "6" })).toBe("gnome-libsecret"); + expect(autoSwitch({ KDE_FULL_SESSION: "true", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("ignores stale session hints when XDG_CURRENT_DESKTOP is authoritative", () => { + expect( + autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), + ).toBe("gnome-libsecret"); + // A previous KDE session left these behind; the compositor running now is not KDE. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", XDG_SESSION_DESKTOP: "GNOME" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:plasma" })).toBe("gnome-libsecret"); + expect( + autoSwitch({ + XDG_CURRENT_DESKTOP: "Hyprland", + XDG_SESSION_DESKTOP: "KDE", + KDE_SESSION_VERSION: "6", + }), + ).toBe("gnome-libsecret"); + }); + + it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit libsecret selection over KDE desktop heuristics", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "gnome-libsecret", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet6", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet", + selectedBackend: "gnome-libsecret", + env: {}, + }), + ).toContain("KWallet"); + }); + + it("uses KWallet remediation wording for KDE-looking sessions", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "kwallet6", + env: {}, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { DESKTOP_SESSION: "plasmawayland" }, + }), + ).toContain("KWallet"); + // A desktop name outranks a bare KDE marker when choosing the wording. + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, + }), + ).toContain("GNOME Keyring"); + }); +}); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts new file mode 100644 index 000000000000..576a58b8567e --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -0,0 +1,178 @@ +export type LinuxPasswordStorePreference = + | "auto" + | "gnome-libsecret" + | "kwallet" + | "kwallet5" + | "kwallet6"; +export type LinuxPasswordStoreSwitch = Exclude; + +export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto"; + +// Chromium matches XDG_CURRENT_DESKTOP values case-sensitively and returns on the first value it +// recognizes, so these stay exact literals and are scanned in order. Omitting a real desktop fails +// safe: we force gnome-libsecret, which is the backend Chromium selects for all of these anyway. +const ELECTRON_LIBSECRET_DESKTOPS = new Set([ + "Deepin", + "GNOME", + "Pantheon", + "UKUI", + "Unity", + "X-Cinnamon", + "XFCE", +]); +// Chromium selects a KWallet generation for KDE from KDE_SESSION_VERSION, so it needs no help. +const ELECTRON_KDE_DESKTOP = "KDE"; +// Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. +const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); + +const KDE_NAME_PREFIXES = ["kde", "plasma"]; +const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); + +export function normalizeLinuxPasswordStorePreference( + value: unknown, +): LinuxPasswordStorePreference { + return value === "gnome-libsecret" || + value === "kwallet" || + value === "kwallet5" || + value === "kwallet6" + ? value + : DEFAULT_LINUX_PASSWORD_STORE; +} + +// Auto mode asks one question: will Electron select a real keyring on its own? If so, stay out of +// the way, which is how canonical KDE sessions keep the KWallet generation Chromium picks for them. +// Otherwise force gnome-libsecret, because the alternative is basic text, which is barely +// encryption at all. Forcing never guesses a KWallet generation; a KDE session that needs a +// specific one sets linuxPasswordStore explicitly. +export function resolveLinuxPasswordStoreSwitch(input: { + readonly preference: LinuxPasswordStorePreference; + readonly env: NodeJS.ProcessEnv; +}): LinuxPasswordStoreSwitch | null { + if (input.preference !== "auto") { + return input.preference; + } + + return electronSelectsProtectedBackend(input.env) ? null : "gnome-libsecret"; +} + +// Only an exact XDG_CURRENT_DESKTOP literal proves Electron will protect the session. Chromium can +// also reach a real backend through DESKTOP_SESSION and the legacy KDE markers, but those are the +// variables a previous session leaves behind, and trusting them is what let stale hints suppress +// the forced backend before. Forcing where Chromium would have chosen libsecret is harmless, since +// it lands on the same backend. +function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { + for (const name of splitDesktopNameList(env.XDG_CURRENT_DESKTOP)) { + const trimmed = name.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed === ELECTRON_KDE_DESKTOP || ELECTRON_LIBSECRET_DESKTOPS.has(trimmed)) { + return true; + } + if (ELECTRON_UNPROTECTED_DESKTOPS.has(trimmed)) { + return false; + } + } + + return false; +} + +export function resolveLinuxSecretStorageUnavailableMessage(input: { + readonly configuredPreference: LinuxPasswordStorePreference; + readonly selectedBackend: string | null; + readonly env: NodeJS.ProcessEnv; +}): string { + if (input.configuredPreference === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + input.configuredPreference === "kwallet" || + input.configuredPreference === "kwallet5" || + input.configuredPreference === "kwallet6" + ) { + return getKWalletRemediationMessage(); + } + + const backend = normalizeSelectedStorageBackend(input.selectedBackend); + if (backend === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" || + looksLikeKdeSession(input.env) + ) { + return getKWalletRemediationMessage(); + } + + return getGnomeKeyringRemediationMessage(); +} + +function getGnomeKeyringRemediationMessage(): string { + return "Command Center could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart Command Center."; +} + +function getKWalletRemediationMessage(): string { + return "Command Center could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart Command Center."; +} + +// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It +// never decides which backend to select, so a loose match costs a user slightly wrong instructions +// rather than an unprotected credential store. +function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { + const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); + if (currentDesktopNames.length > 0) { + return currentDesktopNames.some(isKdeDesktopName); + } + + const legacyNames = legacyDesktopNames(env); + if (legacyNames.length > 0) { + return legacyNames.some(isKdeDesktopName); + } + + return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); +} + +function isKdeDesktopName(name: string): boolean { + return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { + return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function nonEmptyDesktopNames(value: string | undefined): string[] { + return splitDesktopNameList(value).flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function isSet(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + +function isAffirmativeFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; +} + +function splitDesktopNameList(value: string | undefined): string[] { + return value?.split(":") ?? []; +} + +function normalizeDesktopName(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeSelectedStorageBackend(value: string | null): string | null { + const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); + return normalized && normalized.length > 0 ? normalized : null; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b6187ced7cc0..d550124da259 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,12 +43,14 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; @@ -181,6 +183,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( @@ -195,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe( Layer.provideMerge(ElectronApp.layer), ); +const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), +); + +// Acquire strict pre-ready setup before Clerk, whose userData resolution can +// yield and let Electron emit ready. const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => - desktopApplicationLayer.pipe( - Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(electronLayer), - ), + desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), ), + Layer.provideMerge(DesktopPreReadyPlatform.layer), ); // Must run before Electron reaches `ready`, which building the runtime below diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed902..7e8859359b37 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts new file mode 100644 index 000000000000..f49c1cb79f1a --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; + +const keyboardEvent = ( + overrides: Partial[0]> = {}, +) => ({ + key: "Enter", + metaKey: false, + ctrlKey: false, + shiftKey: false, + isComposing: false, + ...overrides, +}); + +describe("resolveAnnotationSubmission", () => { + it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { + expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach"); + expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send"); + expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send"); + }); + + it("leaves Shift+Enter and composition events available for editing", () => { + expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.ts b/apps/desktop/src/preview/AnnotationKeyboard.ts new file mode 100644 index 000000000000..6c694ccd2ed5 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.ts @@ -0,0 +1,16 @@ +import type { PreviewAnnotationSubmission } from "@t3tools/contracts"; + +interface AnnotationKeyboardEvent { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly isComposing: boolean; +} + +export function resolveAnnotationSubmission( + event: AnnotationKeyboardEvent, +): PreviewAnnotationSubmission | null { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null; + return event.metaKey || event.ctrlKey ? "send" : "attach"; +} diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..e11d25bbed77 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // Deliberately NOT local-fonts: preview sessions run untrusted web content, + // and silently granting it would hand every page the user's installed-font + // fingerprint (and font file bytes via FontData.blob()). The app's own font + // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 684d6655da5b..a6ef30c2742a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -36,6 +36,28 @@ describe("fitPictureInPictureContentSize", () => { }); }); +describe("isPreviewRefreshShortcut", () => { + const input = (overrides: Partial = {}) => + ({ + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + ...overrides, + }) as Electron.Input; + + it("recognizes the platform refresh chord without matching modified variants", () => { + expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true); + expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe( + true, + ); + expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false); + expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false); + }); +}); + const { browserWindowConstructor, createFromPath, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d7e8376cec74..169fe2992dca 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -11,6 +11,7 @@ import type { DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, + PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewScreenshotArtifact, @@ -109,7 +110,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; -const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", @@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + input.key.toLowerCase() === "r" && + (input.meta || input.control) && + !input.shift && + !input.alt; + const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { @@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + if (isPreviewRefreshShortcut(input)) { + event.preventDefault(); + runFork( + attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => + wc.reload(), + ).pipe(Effect.ignore), + ); + return; + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( @@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); - return yield* Effect.callback( + return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { @@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( - payload: PreviewAnnotationPayload | null, + payload: PreviewAnnotationSubmissionResult | null, ) { const active = (yield* Ref.get(pickSessionsRef)).get(tabId); if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); - const settle = (payload: PreviewAnnotationPayload | null) => { + const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { @@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const cropRect = normalizeCaptureRect(args[1]); + const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle(payload)), - onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), + onSuccess: (screenshot) => + Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), }), Effect.ensuring( attempt( @@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b8981021..d03673400ab5 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -11,8 +11,10 @@ import type { PreviewAnnotationRegionTarget, PreviewAnnotationStrokeTarget, PreviewAnnotationStyleChange, + PreviewAnnotationSubmission, } from "@t3tools/contracts"; +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -426,7 +428,7 @@ function startAnnotation(): void { "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; composerRow.appendChild(dragHandle); - const submit = createButton("Attach", "Attach annotation and screenshot"); + const submit = createButton("Attach", "Attach annotation and screenshot (Enter)"); submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); @@ -1182,7 +1184,7 @@ function startAnnotation(): void { refreshToolButtons(); }; - submit.addEventListener("click", () => { + const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1223,13 +1225,18 @@ function startAnnotation(): void { ...regions.map((region) => region.rect), ...strokes.map((stroke) => stroke.bounds), ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); }); - }); - comment.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + }; + submit.addEventListener("click", () => submitAnnotation("attach")); + root.addEventListener("keydown", (event) => { + const submission = event.target === comment ? resolveAnnotationSubmission(event) : null; + // Keep this in the bubble phase so editor inputs receive the event before + // it is isolated from listeners installed by the inspected page. + event.stopImmediatePropagation(); + if (!submission) return; event.preventDefault(); - submit.click(); + submitAnnotation(submission); }); window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index ff1531f08f3c..6104532e68d0 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; const require = NodeModule.createRequire(import.meta.url); -const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36ad0..64c59749abe9 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey( + Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), + ), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +105,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +125,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +134,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -235,6 +241,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -268,6 +275,44 @@ describe("DesktopSettings", () => { ), ); + it.effect( + "normalizes unsupported linux password-store values without dropping other settings", + () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + "linuxPasswordStore": "unsupported-store", + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + "updateChannel": "nightly", + "updateChannelConfiguredByUser": true + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", + mainWindowBounds: null, + mainWindowMaximized: false, + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, + } satisfies DesktopAppSettings.DesktopSettings); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -300,6 +345,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +373,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +400,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5f8a..aefc67525531 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -16,10 +16,16 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { + document.linuxPasswordStore = settings.linuxPasswordStore; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..c1cb8588b5ea 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,14 +13,23 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", + fontSizeCode: 13, + fontSizeInterface: 16, + fontSizePrompt: 14, + fontSizeTerminal: 12, + fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index ec70308b3d34..05b1ca144444 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80ae74..06b6b1565817 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -90,7 +91,7 @@ function runShellEnvironment(input: { }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), ), ), ); @@ -163,12 +164,12 @@ describe("DesktopShellEnvironment", () => { platform: "linux", handler: () => envOutput({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + PATH: "/opt/linuxbrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", }), }); - assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + assert.equal(env.PATH, "/opt/linuxbrew/bin:/usr/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); }), ); @@ -243,6 +244,65 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_CURRENT_DESKTOP: "wrong-launcher", + XDG_SESSION_DESKTOP: "wrong-launcher", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/opt/linuxbrew/bin:/usr/bin", + XDG_CURRENT_DESKTOP: "KDE", + XDG_SESSION_DESKTOP: "KDE", + XDG_SESSION_TYPE: "wayland", + }), + }); + + assert.equal(env.XDG_CURRENT_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_TYPE, "wayland"); + }), + ); + + it.effect("overrides stale dbus session addresses from the login shell", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); + }), + ); + + it("resolves dbus runtime dir candidates with existence checks", () => { + const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ + env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, + uid: 1000, + exists: (path) => path === "/run/user/1000/bus", + }); + + assert.equal(busPath, "unix:path=/run/user/1000/bus"); + }); + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7a53..5627eec54ded 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", + "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; @@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); + +const linuxRuntimeDirCandidates = ( + env: NodeJS.ProcessEnv, + uid: number | undefined, +): ReadonlyArray => { + const candidates: string[] = []; + const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); + if (Option.isSome(fromEnv)) { + candidates.push(normalizeRuntimeDir(fromEnv.value)); + } + if (uid !== undefined) { + candidates.push(`/run/user/${uid}`); + } + return candidates.filter((candidate) => candidate.length > 0); +}; + +function resolveDefaultLinuxDbusSessionBusPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly uid: number | undefined; + readonly exists?: (path: string) => boolean; +}): string | null { + for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { + const busPath = `${runtimeDir}/bus`; + if (input.exists === undefined || input.exists(busPath)) { + return busPath; + } + } + + return null; +} + +export function resolveDefaultLinuxDbusSessionBusAddress(input: { + readonly env: NodeJS.ProcessEnv; + readonly exists: (path: string) => boolean; + readonly uid: number | undefined; +}): string | null { + const busPath = resolveDefaultLinuxDbusSessionBusPath(input); + return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; +} + const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; @@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { + ): Effect.fn.Return< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + > { + const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { @@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + const shellPreferredEnvNames = [ + "DBUS_SESSION_BUS_ADDRESS", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + ] as const; + for (const name of shellPreferredEnvNames) { + if (shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + for (const name of [ + "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "WAYLAND_DISPLAY", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } + + if ( + config.platform === "linux" && + Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) + ) { + for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { + const dbusSessionBusPath = `${runtimeDir}/bus`; + const busExists = yield* fileSystem + .exists(dbusSessionBusPath) + .pipe(Effect.orElseSucceed(() => false)); + if (busExists) { + config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; + break; + } + } + } }, ); const installShellEnvironment = ( config: ShellEnvironmentConfig, -): Effect.Effect => { +): Effect.Effect => { if (config.platform === "win32") { return installWindowsEnvironment(config); } @@ -411,6 +496,7 @@ const installShellEnvironment = ( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = installShellEnvironment({ @@ -418,6 +504,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, userShell: Option.none(), }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 36cdcb50b6ba..112c0ab350ee 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -43,6 +43,7 @@ function makeElectronAppLayer( setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 0f33a2563a3f..0ab83cbe5b41 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,11 +46,13 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), + pickFiles: () => Effect.succeed([]), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ec000031960f..f85279b8a6b0 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -439,6 +439,51 @@ describe("DesktopWindow", () => { }), ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + if (!beforeInput) { + return yield* Effect.die("before-input-event listener was not registered"); + } + + let prevented = false; + const event = { preventDefault: () => (prevented = true) }; + const input = { + type: "keyDown", + isAutoRepeat: true, + key: "W", + meta: true, + control: false, + alt: false, + shift: false, + }; + beforeInput(event, input); + assert.isTrue(prevented); + + prevented = false; + beforeInput(event, { ...input, isAutoRepeat: false }); + assert.isFalse(prevented); + + prevented = false; + beforeInput(event, { ...input, meta: false }); + assert.isFalse(prevented); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 40788009d3b3..3bf746a8e9b6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -25,6 +26,12 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; +// Renderer crash (usually V8 OOM on long sessions) recovery: reload after a +// short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer +// that dies on boot cannot reload-loop forever. +const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; +const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; +const RENDERER_RECOVERY_WINDOW_MS = 60_000; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED -7, // ERR_TIMED_OUT @@ -514,6 +521,18 @@ export const make = Effect.gen(function* () { } }); + // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the + // close-terminal shortcut can outlive the terminal that handled its first + // press, so reject repeats before they reach the native window accelerator. + // Deliberate presses still flow through the renderer or native menu. + window.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || !input.isAutoRepeat) return; + const modifier = environment.platform === "darwin" ? input.meta : input.control; + if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { + event.preventDefault(); + } + }); + window.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(environment.displayName); @@ -537,6 +556,7 @@ export const make = Effect.gen(function* () { let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; + let rendererRecoveryTimestamps: number[] = []; const clearDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber === undefined) { return; @@ -618,10 +638,39 @@ export const make = Effect.gen(function* () { }, ); window.webContents.on("render-process-gone", (_event, details) => { - void runPromise( - logWindowWarning("main window render process gone", { - reason: details.reason, - exitCode: details.exitCode, + const recoverable = + details.reason === "crashed" || + details.reason === "oom" || + details.reason === "abnormal-exit"; + // Long sessions can OOM the renderer (V8 heap exhaustion from + // accumulated thread state). Without a reload the user is left staring + // at a dead white window while agents keep running invisibly, so + // recover by reloading — the renderer rehydrates from the backend, + // which is unaffected. Recovery attempts are bounded so a renderer + // that dies immediately on boot cannot reload-loop forever. + runFork( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter( + (timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS, + ); + const shouldRecover = + recoverable && + !window.isDestroyed() && + rendererRecoveryTimestamps.length < RENDERER_RECOVERY_MAX_ATTEMPTS; + yield* logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + recovering: shouldRecover, + }); + if (!shouldRecover) { + return; + } + rendererRecoveryTimestamps.push(now); + yield* Effect.sleep(RENDERER_RECOVERY_RELOAD_DELAY_MS); + if (!window.isDestroyed()) { + loadApplication(); + } }), ); }); diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cfa..5557f5fb6b19 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site"; + ); +} + +function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { + const status = group.workflow.status; + return ( + status !== "completed" && + status !== "failed" && + status !== "cancelled" && + status !== "interrupted" + ); +} + +function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { + return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; +} + +/** + * Phase rail: the run's shape at a glance. One segment per phase in order, + * separated by chevrons; each segment shows title + one dot per member. + * The whole arc (done → live → pending) is visible without scrolling the + * member list. + */ +function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { + if (group.phases.length === 0) { + return null; + } + return ( +
+ {group.phases.map((phase, index) => ( +
+ {index > 0 ? ( + + ) : null} +
+ + {phase.state === "done" ? "✓ " : ""} + {phase.title} + + + {phase.members.length === 0 ? ( + + ) : ( + phase.members.map((member) => ) + )} + +
+
+ ))} +
+ ); +} + +/** + * Read-only workflow script viewer, fetched through the contained + * getWorkflowScript RPC (never a raw filesystem read from the client). + */ +function WorkflowScriptView({ + environmentId, + threadId, + scriptPath, + onClose, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; + scriptPath: string; + onClose: () => void; +}) { + const result = useAtomValue( + orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), + ); + return ( +
+
+ + + {scriptPath.split("/").at(-1)} + + +
+
+ {result._tag === "Success" ? ( +
+            {result.value.contents}
+            {result.value.truncated ? "\n… (truncated)" : ""}
+          
+ ) : result._tag === "Failure" ? ( +

Could not load the script.

+ ) : ( +

Loading…

+ )} +
+
+ ); +} + +/** + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. + */ +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + + return ( +
+ + {open ? phase.members.map((member) => ) : null} +
+ ); +} + +/** Expanded workflow: phase rail + full phase tree. */ +function ExpandedWorkflowSection({ + group, + environmentId, + threadId, + onCollapse, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + onCollapse: () => void; +}) { + const [scriptOpen, setScriptOpen] = useState(false); + const members = workflowMembers(group); + const settled = members.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; + const scriptPath = group.workflow.runHandles?.scriptPath; + const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; + return ( +
+
+ + + {group.workflow.workflowName ?? group.workflow.title} + + {canShowScript ? ( + + ) : null} + + {settled}/{members.length} settled + + +
+ + {scriptOpen && canShowScript ? ( + setScriptOpen(false)} + /> + ) : null} + {group.phases.map((phase) => ( + + ))} + {group.unphasedMembers.map((member) => ( + + ))} + {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( + + ) : null} +
+ ); +} + +/** + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. + */ +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { + const members = workflowMembers(group); + const failed = members.filter((member) => member.status === "failed").length; + // Coordinator usage may already aggregate members (panel-footer rule): + // count it only when there are no member rows to sum. + const totalTokens = members.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, + ); + const elapsed = + group.workflow.startedAt && group.workflow.completedAt + ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) + : null; + return ( +
+ +
+ ); +} + +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + +export function AgentsPanel({ + model, + environmentId = null, + threadId = null, +}: { + model: AgentPanelModel; + environmentId?: EnvironmentId | null; + threadId?: ThreadId | null; +}) { + if (!model.hasAgents) { + return ( +
+ +

No agents yet

+

+ When this thread spawns subagents or runs a workflow, they show up here with live status, + activity, and token usage. +

+
+ ); + } + + return ( +
+ +
+ {model.workflows.map((group) => ( + + ))} + {model.directAgents.length > 0 ? ( +
+
+ Direct spawns +
+ {model.directAgents.map((agent) => ( + + ))} +
+ ) : null} +
+
+
+ + {model.runningCount + model.waitingCount > 0 ? ( + + ● {model.runningCount + model.waitingCount} working + + ) : null} + {model.idleCount > 0 ? {model.idleCount} idle : null} + {model.settledCount > 0 ? {model.settledCount} settled : null} + + Σ {formatSubagentTokenCount(model.totalTokens)} tok +
+
+ ); +} diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..36d42a60fa81 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -421,6 +422,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..485ffbf8d37f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0ff..440f48d7c90a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -44,6 +44,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; @@ -214,9 +215,102 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable (they collapse to invisible absolute boxes, + * which keep their natural width), so the required width can be recomputed in + * either state on every pass - no remembered widths that could go stale or + * latch the strip compact. A small hysteresis keeps the boundary from + * flapping between states. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; + +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; + + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and absolutely-positioned nodes (the compact-hidden labels). + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } + if (compact) { + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); + } + } + setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + }, []); + + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { + measure(); + }); + + useEffect(() => { + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -300,12 +394,18 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return ( -
- {isMobile ? ( +
+ {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null}
)} - + {showGitControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9b4cbf2b4a41..bbd27f65ab0d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -749,7 +749,12 @@ export function BranchToolbarBranchSelector({ disabled={isInitialBranchesLoadPending || isBranchActionPending} > - {triggerLabel} + + {triggerLabel} + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..ca778daad31c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..2cf99547752a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); } @@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + + + diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39c..1335e6bb05b2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -594,7 +594,7 @@ function MarkdownCodeBlock({ return (
@@ -606,7 +606,7 @@ function MarkdownCodeBlock({ theme={theme} /> - + (() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, @@ -1593,6 +1596,7 @@ function ChatMarkdown({ text, threadRef, ]); + /* eslint-enable react/no-unstable-nested-components */ return (
; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -1218,10 +1234,24 @@ function ChatViewContent(props: ChatViewProps) { [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1231,7 +1261,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1301,12 +1330,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // Tracks whether the user explicitly dismissed the sidebar for the active turn. - const planSidebarDismissedForTurnRef = useRef(null); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1456,8 +1480,13 @@ function ChatViewContent(props: ChatViewProps) { ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const [efficiencyRoutingOverride, setEfficiencyRoutingOverride] = useState<{ readonly mode: "manual" | "auto"; @@ -1571,10 +1600,9 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - useEffect(() => { if (!activeThreadRef) return; useRightPanelStore @@ -1600,36 +1628,29 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); + // Reading a finished thread clears the sidebar's Done badge. The visit is + // stamped at the turn's completion time — not now/updatedAt — so it clears + // exactly the completion the user is looking at: a wake or completion that + // lands later still gets its signal (markThreadVisited never moves the + // timestamp backwards). + useEffect(() => { + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + completedAt, + ); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1765,6 +1786,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1871,25 +1900,6 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); - const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -1953,6 +1963,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: (