mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-12 18:31:18 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2911ac6e6 | ||
|
|
a8f35ed484 | ||
|
|
0cec10748e |
@@ -1,65 +0,0 @@
|
||||
name: clang-tidy fix
|
||||
|
||||
# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a
|
||||
# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the
|
||||
# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact.
|
||||
#
|
||||
# Every push to a PR starts this workflow, and its job skips on that first
|
||||
# attempt. The bot re-runs it on request. A re-run keeps the pull_request
|
||||
# context, so the PR's code runs with a read-only token, no secrets and the
|
||||
# PR's own cache, never main's, whoever asked for it.
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
if: github.run_attempt > 1
|
||||
# A hosted runner, so nothing the PR's code leaves behind outlives the job.
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: 'false'
|
||||
# The PR merge commit plus its first parent, the base it is diffed against.
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
# Parsing needs the dependency headers, not a build of this PR's deps/, so
|
||||
# a PR that changes deps/ falls back to the newest cache main has.
|
||||
- name: Restore cached deps
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
|
||||
key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}
|
||||
restore-keys: linux-clang-cache-orcaslicer_deps-build-
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Apt-Install Dependencies
|
||||
uses: ./.github/actions/apt-install-deps
|
||||
|
||||
# The status says whether findings remain that --fix cannot fix.
|
||||
- name: Fix the changed lines
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/clang-tidy-fix"
|
||||
status=0
|
||||
scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$?
|
||||
echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status"
|
||||
git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \
|
||||
> "$RUNNER_TEMP/clang-tidy-fix/fix.patch"
|
||||
|
||||
- name: Upload the fix
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clang-tidy-fix
|
||||
path: ${{ runner.temp }}/clang-tidy-fix/
|
||||
retention-days: 7
|
||||
# A later re-run of the same run replaces the earlier fix.
|
||||
overwrite: true
|
||||
@@ -1,476 +0,0 @@
|
||||
name: PR clang-tidy Bot
|
||||
|
||||
# Helps a pull request past the clang-tidy check.
|
||||
#
|
||||
# When the check fails on a PR's changes, a comment asks the author to fix it,
|
||||
# either with scripts/run_clang_tidy.sh --fix or by commenting:
|
||||
# /bot fix-tidy
|
||||
#
|
||||
# That command, from the PR's author or an account with write access, re-runs
|
||||
# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the
|
||||
# PR's own context. When it finishes, the fix is posted as one review: each
|
||||
# include whose place falls on a line the PR's diff shows is a suggestion GitHub
|
||||
# can commit in one click, and the review's body holds the whole patch with a
|
||||
# git apply command. Findings --fix cannot fix, such as a using in a header,
|
||||
# are left for the check to report.
|
||||
#
|
||||
# No code from the PR runs here. Nothing is pushed, and the patch is only
|
||||
# posted when every line it adds is an #include in a C++ file the PR changes.
|
||||
#
|
||||
# workflow_run matches the workflows by their name: keep "clang-tidy" and
|
||||
# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and
|
||||
# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
workflow_run:
|
||||
workflows:
|
||||
- clang-tidy
|
||||
- clang-tidy fix
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
request:
|
||||
# Skips the job unless a PR comment mentions the command.
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '/bot fix-tidy')
|
||||
permissions:
|
||||
actions: write # re-run the fix run
|
||||
pull-requests: write # react to and answer the comment, which is the PR's
|
||||
issues: write # feedback comment + reactions
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Re-run the PR's clang-tidy fix
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const MARKER = '<!-- pr-tidy-bot -->';
|
||||
const FIX_WORKFLOW = 'clang_tidy_fix.yml';
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const issue = context.payload.issue;
|
||||
const comment = context.payload.comment;
|
||||
|
||||
// Ignores a comment whose sender is not its author.
|
||||
if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') {
|
||||
core.info('Ignoring a comment not written by its sender, or by a bot.');
|
||||
return;
|
||||
}
|
||||
|
||||
const commandLine = (comment.body || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^\/bot\s+fix-tidy\b/i.test(line));
|
||||
if (!commandLine) {
|
||||
core.info('No /bot fix-tidy command found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const commenter = comment.user.login;
|
||||
// Answers only the PR's author and maintainers, so the command cannot be used to spam.
|
||||
if (commenter !== issue.user.login) {
|
||||
const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
|
||||
if (!['admin', 'write'].includes(access.permission)) {
|
||||
core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const react = (content) =>
|
||||
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
|
||||
async function refuse(reason) {
|
||||
await react('-1');
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
|
||||
}
|
||||
|
||||
await react('eyes');
|
||||
|
||||
if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) {
|
||||
return refuse('`/bot fix-tidy` takes no arguments.');
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number });
|
||||
if (pr.state !== 'open') {
|
||||
return refuse('the PR is not open.');
|
||||
}
|
||||
|
||||
const sha = pr.head.sha.slice(0, 10);
|
||||
const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
|
||||
owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
|
||||
});
|
||||
const run = runs[0];
|
||||
// A PR last pushed before clang_tidy_fix.yml existed has no run to re-run.
|
||||
if (!run) {
|
||||
return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` +
|
||||
'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.');
|
||||
}
|
||||
if (run.status !== 'completed') {
|
||||
// The first attempt is the one every push starts, and it makes no fix.
|
||||
return refuse(run.run_attempt > 1
|
||||
? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.`
|
||||
: `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`);
|
||||
}
|
||||
// GitHub refuses to re-run a run created over 30 days ago.
|
||||
try {
|
||||
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
|
||||
} catch (error) {
|
||||
core.warning(`Re-running ${run.html_url} failed: ${error.message}`);
|
||||
return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` +
|
||||
'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.');
|
||||
}
|
||||
core.info(`Re-ran ${run.html_url}`);
|
||||
|
||||
suggest:
|
||||
# The fix run's re-runs only. Its first attempt makes no fix.
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'workflow_run'
|
||||
&& github.event.workflow_run.name == 'clang-tidy fix'
|
||||
&& github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.run_attempt > 1
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
actions: read # download the fix
|
||||
contents: read # check out the PR head
|
||||
pull-requests: write # the review
|
||||
issues: write # feedback comment + reactions
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Find the PR and who asked
|
||||
id: find
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const MARKER = '<!-- pr-tidy-bot -->';
|
||||
const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const run = context.payload.workflow_run;
|
||||
|
||||
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
|
||||
});
|
||||
const pr = prs[0];
|
||||
if (!pr) {
|
||||
core.info('No open PR for this run.');
|
||||
return;
|
||||
}
|
||||
|
||||
// The latest /bot fix-tidy comment is the one this run answers.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || ''));
|
||||
const mention = request ? `@${request.user.login} ` : '';
|
||||
async function refuse(reason) {
|
||||
if (request) {
|
||||
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' });
|
||||
}
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` });
|
||||
}
|
||||
|
||||
if (pr.head.sha !== run.head_sha) {
|
||||
return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.');
|
||||
}
|
||||
if (run.conclusion !== 'success') {
|
||||
return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`);
|
||||
}
|
||||
|
||||
// The right-side lines of each changed C++ file's diff: the lines a
|
||||
// review comment can be placed on. A file too large for GitHub to
|
||||
// show its diff has none, and gets no suggestions.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 });
|
||||
const ranges = {};
|
||||
for (const file of files) {
|
||||
if (file.status === 'removed' || !CPP.test(file.filename)) {
|
||||
continue;
|
||||
}
|
||||
ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)]
|
||||
.map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1])
|
||||
.filter(([first, last]) => last >= first);
|
||||
}
|
||||
|
||||
core.setOutput('pr', pr.number);
|
||||
core.setOutput('head-sha', pr.head.sha);
|
||||
core.setOutput('head-repo', run.head_repository.full_name);
|
||||
core.setOutput('ranges', JSON.stringify(ranges));
|
||||
core.setOutput('mention', mention);
|
||||
core.setOutput('request-id', request ? request.id : '');
|
||||
|
||||
- name: Download the fix
|
||||
if: steps.find.outputs.pr != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: clang-tidy-fix
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/clang-tidy-fix
|
||||
|
||||
# The PR's own files, only to apply the patch to. Nothing in them is run.
|
||||
- name: Checkout the PR head
|
||||
if: steps.find.outputs.pr != ''
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ steps.find.outputs.head-repo }}
|
||||
ref: ${{ steps.find.outputs.head-sha }}
|
||||
path: head
|
||||
fetch-depth: 1
|
||||
lfs: 'false'
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
src
|
||||
tests
|
||||
|
||||
# The fix was made on the PR's merge commit, so it is applied to the head
|
||||
# and diffed again, giving line numbers in the PR's own files.
|
||||
- name: Check the fix and place its suggestions
|
||||
id: place
|
||||
if: steps.find.outputs.pr != ''
|
||||
env:
|
||||
RANGES: ${{ steps.find.outputs.ranges }}
|
||||
run: |
|
||||
python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF'
|
||||
import json, os, re, subprocess, sys
|
||||
|
||||
patch_path, head, out = sys.argv[1:]
|
||||
ranges = json.loads(os.environ["RANGES"])
|
||||
|
||||
def output(**values):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
for key, value in values.items():
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
patch = open(patch_path, encoding="utf-8").read()
|
||||
if not patch.strip():
|
||||
output(result="empty")
|
||||
sys.exit()
|
||||
|
||||
# Every line the patch adds must be an #include or blank, in a C++ file the
|
||||
# PR changes, and it may not remove, create, rename or change the mode of
|
||||
# anything.
|
||||
include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*')
|
||||
header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
|
||||
problems = []
|
||||
path = None
|
||||
for line in patch.splitlines():
|
||||
if line.startswith("diff --git "):
|
||||
match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
|
||||
path = match.group(1) if match else None
|
||||
if path not in ranges:
|
||||
problems.append(f"{line}: not a C++ file the PR changes")
|
||||
# git appends a tab to the --- and +++ lines of a path that contains a space.
|
||||
elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line):
|
||||
pass
|
||||
elif line.startswith(" ") or line == "\\ No newline at end of file":
|
||||
pass
|
||||
elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()):
|
||||
pass
|
||||
else:
|
||||
problems.append(f"{path}: {line}")
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"::error::Refused patch line {problem}")
|
||||
output(result="rejected")
|
||||
sys.exit()
|
||||
|
||||
if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0:
|
||||
output(result="apply-failed")
|
||||
sys.exit()
|
||||
|
||||
git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff",
|
||||
"--src-prefix=a/", "--dst-prefix=b/"]
|
||||
with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f:
|
||||
f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout)
|
||||
|
||||
# Each run of added lines becomes one suggestion on the line it goes after
|
||||
# (before, at the top of a file): that line, replaced by itself and the
|
||||
# includes. It can only sit on a line the PR's diff shows.
|
||||
hunks = []
|
||||
path = None
|
||||
for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines():
|
||||
if line.startswith("diff --git "):
|
||||
path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1)
|
||||
elif line.startswith("@@ "):
|
||||
hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), []))
|
||||
elif hunks and line.startswith("+") and not line.startswith("+++ "):
|
||||
hunks[-1][2].append(line[1:])
|
||||
|
||||
suggestions = []
|
||||
total = suggested = 0
|
||||
for path, after, added in hunks:
|
||||
count = sum(bool(include.fullmatch(line)) for line in added)
|
||||
total += count
|
||||
original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True,
|
||||
encoding="utf-8").stdout.splitlines()
|
||||
line = max(after, 1)
|
||||
if line > len(original) or not any(first <= line <= last for first, last in ranges[path]):
|
||||
continue
|
||||
body = "\n".join([original[line - 1], *added] if after else [*added, original[0]])
|
||||
# A fence longer than any run of backticks in the line it repeats.
|
||||
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)])
|
||||
suggestions.append({"path": path, "line": line, "side": "RIGHT",
|
||||
"body": f"{fence}suggestion\n{body}\n{fence}"})
|
||||
suggested += count
|
||||
with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(suggestions, f)
|
||||
output(result="ok", includes=total, suggested=suggested)
|
||||
EOF
|
||||
|
||||
- name: Post the fix
|
||||
if: always() && steps.find.outputs.pr != ''
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR: ${{ steps.find.outputs.pr }}
|
||||
MENTION: ${{ steps.find.outputs.mention }}
|
||||
REQUEST_ID: ${{ steps.find.outputs.request-id }}
|
||||
HEAD_SHA: ${{ steps.find.outputs.head-sha }}
|
||||
RESULT: ${{ steps.place.outputs.result }}
|
||||
INCLUDES: ${{ steps.place.outputs.includes }}
|
||||
SUGGESTED: ${{ steps.place.outputs.suggested }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const MARKER = '<!-- pr-tidy-bot -->';
|
||||
// GitHub's limit on a review body is 65536 characters.
|
||||
const MAX_PATCH = 60000;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env;
|
||||
const issue_number = Number(PR);
|
||||
const sha = HEAD_SHA.slice(0, 10);
|
||||
const fixUrl = context.payload.workflow_run.html_url;
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
|
||||
const react = (content) => REQUEST_ID
|
||||
? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content })
|
||||
: Promise.resolve();
|
||||
async function reply(body, content) {
|
||||
await react(content);
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` });
|
||||
}
|
||||
|
||||
const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8');
|
||||
const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0';
|
||||
|
||||
if (RESULT === 'empty') {
|
||||
return passes
|
||||
? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1')
|
||||
: reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1');
|
||||
}
|
||||
if (RESULT === 'rejected') {
|
||||
return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1');
|
||||
}
|
||||
if (RESULT === 'apply-failed') {
|
||||
return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1');
|
||||
}
|
||||
if (RESULT !== 'ok') {
|
||||
return reply(`something went wrong. See the [run log](${runUrl}).`, '-1');
|
||||
}
|
||||
|
||||
const patch = read('head.patch');
|
||||
const suggestions = JSON.parse(read('suggestions.json'));
|
||||
|
||||
const one = INCLUDES === '1';
|
||||
const includes = one ? '1 missing include' : `${INCLUDES} missing includes`;
|
||||
const inline = SUGGESTED === '0'
|
||||
? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.'
|
||||
: 'They go on lines this PR does not change, so I can\'t suggest them inline.')
|
||||
: SUGGESTED === INCLUDES
|
||||
? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.')
|
||||
: `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`;
|
||||
const remaining = passes
|
||||
? `With ${one ? 'it' : 'them'}, clang-tidy passes.`
|
||||
: `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`;
|
||||
// A fence longer than any run of backticks in the patch's context lines.
|
||||
const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1)));
|
||||
const apply = patch.length > MAX_PATCH
|
||||
? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.`
|
||||
: `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`;
|
||||
const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` +
|
||||
`${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`;
|
||||
|
||||
const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' };
|
||||
try {
|
||||
await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions });
|
||||
} catch (error) {
|
||||
// A suggestion GitHub will not place still leaves the patch.
|
||||
core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`);
|
||||
await github.rest.pulls.createReview({ ...review, body: body(false) });
|
||||
}
|
||||
await react('+1');
|
||||
|
||||
nudge:
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'workflow_run'
|
||||
&& github.event.workflow_run.name == 'clang-tidy'
|
||||
&& github.event.workflow_run.event == 'pull_request'
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
actions: read # the check's steps
|
||||
pull-requests: write # comment on the PR
|
||||
issues: write # the comment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Ask for a fix when the check fails
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const MARKER = '<!-- pr-tidy-bot-nudge -->';
|
||||
// The clang-tidy step's name in clang_tidy.yml. A failure in any other
|
||||
// step is the CI's, not the PR's.
|
||||
const TIDY_STEP = 'Run clang-tidy on the changed lines';
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const run = context.payload.workflow_run;
|
||||
|
||||
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
|
||||
});
|
||||
const pr = prs.find((p) => p.head.sha === run.head_sha);
|
||||
if (!pr) {
|
||||
core.info('No open PR at this run\'s commit; a newer run will answer.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id });
|
||||
const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure'));
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER));
|
||||
|
||||
if (!failed) {
|
||||
// Removes the request once the check passes.
|
||||
if (previous && run.conclusion === 'success') {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` +
|
||||
`at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` +
|
||||
'- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' +
|
||||
'- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' +
|
||||
'Other findings, such as a `using` in a header, need a manual edit.';
|
||||
// Edits the earlier request instead of posting another, so each push does not notify again.
|
||||
if (previous) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
}
|
||||
@@ -548,6 +548,20 @@ body
|
||||
overflow-y:auto;
|
||||
}
|
||||
|
||||
/*---- Cloud mode: OrcaCloud is a native pane to the right of the app panel, so this page
|
||||
is reduced to its left nav column (the class is toggled by SetCloudMode in js/home.js). ----*/
|
||||
body.cloud-mode #RightBoard
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.cloud-mode #LeftBoard
|
||||
{
|
||||
/*---- 262px of column plus its 1px separator is the width the app gives the nav. ----*/
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#ContentBoard
|
||||
{
|
||||
overflow-y:auto;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<div class="BtnIcon "><img class="LeftIcon" src="img/i2.png" /></div>
|
||||
<div data-i18n="recent">Recent</div>
|
||||
</div>
|
||||
<div class="BtnItem BtnShortcut" onClick="OpenUrlInLocalBrowser('https://cloud.orcaslicer.com')">
|
||||
<div class="BtnItem BtnShortcut" onClick="GotoCloud()">
|
||||
<div class="BtnIcon "><img class="ShortcutBrandIcon" src="../image/logo.png" /></div>
|
||||
<div class="ShortcutTextRow">
|
||||
<div>OrcaCloud</div>
|
||||
|
||||
@@ -145,8 +145,36 @@ function HandleStudio( pVal )
|
||||
}
|
||||
}
|
||||
|
||||
/*---- OrcaCloud opens as a native pane beside this page, so the shell only switches its
|
||||
own cloud-mode state and asks the app to show or hide that pane (WebViewPanel::set_cloud_mode). ----*/
|
||||
var m_CloudMode=false;
|
||||
|
||||
function SetCloudMode( bOn )
|
||||
{
|
||||
if( m_CloudMode==bOn )
|
||||
return;
|
||||
|
||||
m_CloudMode=bOn;
|
||||
$("body").toggleClass("cloud-mode", bOn);
|
||||
}
|
||||
|
||||
function GotoCloud()
|
||||
{
|
||||
$(".BtnItem").removeClass("BtnItemSelected");
|
||||
$(".BtnShortcut").addClass("BtnItemSelected");
|
||||
SetCloudMode(true);
|
||||
SendSimpleCommand("homepage_open_cloud");
|
||||
}
|
||||
|
||||
function GotoMenu( strMenu )
|
||||
{
|
||||
/*---- Any board in the right region means leaving the cloud pane. ----*/
|
||||
if( m_CloudMode )
|
||||
{
|
||||
SetCloudMode(false);
|
||||
SendSimpleCommand("homepage_close_cloud");
|
||||
}
|
||||
|
||||
let MenuList=$(".BtnItem");
|
||||
let nAll=MenuList.length;
|
||||
|
||||
|
||||
@@ -5419,6 +5419,20 @@ std::string GUI_App::handle_web_request(std::string cmd)
|
||||
else if (command_str.compare("homepage_openproject") == 0) {
|
||||
this->request_open_project({});
|
||||
}
|
||||
else if (command_str.compare("homepage_open_cloud") == 0) {
|
||||
// Deferred: set_cloud_mode() may create a second native webview, which must
|
||||
// not run inside this webview's script-message callback.
|
||||
CallAfter([this]() {
|
||||
if (WebViewPanel* home = WebViewPanel::if_built())
|
||||
home->set_cloud_mode(true, cloud_webview_handoff());
|
||||
});
|
||||
}
|
||||
else if (command_str.compare("homepage_close_cloud") == 0) {
|
||||
CallAfter([]() {
|
||||
if (WebViewPanel* home = WebViewPanel::if_built())
|
||||
home->set_cloud_mode(false);
|
||||
});
|
||||
}
|
||||
else if (command_str.compare("get_recent_projects") == 0) {
|
||||
if (mainframe) {
|
||||
if (WebViewPanel* home = WebViewPanel::if_built()) {
|
||||
@@ -5835,6 +5849,22 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
|
||||
}*/
|
||||
}
|
||||
|
||||
wxString GUI_App::cloud_webview_handoff()
|
||||
{
|
||||
auto orca = m_agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent()) : nullptr;
|
||||
if (!orca || !orca->is_user_login())
|
||||
return {};
|
||||
// Never block the UI thread on a token refresh: that can take a cross-process lock plus a
|
||||
// synchronous POST and retry. If the access token is missing or within a minute of expiry,
|
||||
// skip the handoff for this open — set_cloud_mode then loads the plain cloud page and the next
|
||||
// open retries. The pane is the app's guest: the app owns the session and re-injects a
|
||||
// freshly-refreshed copy on each open (Supabase rotates refresh tokens, so a concurrent
|
||||
// refresh by the SPA can invalidate the app's copy — a known, accepted trade-off).
|
||||
if (orca->access_token_needs_refresh(std::chrono::seconds(60)))
|
||||
return {};
|
||||
return from_u8(orca->build_webview_handoff_url());
|
||||
}
|
||||
|
||||
void GUI_App::on_user_login_handle(wxCommandEvent &evt)
|
||||
{
|
||||
if (!m_agent) { return; }
|
||||
@@ -5890,6 +5920,12 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt)
|
||||
if (app_config->get("sync_user_preset") == "true") {
|
||||
start_sync_user_preset();
|
||||
}
|
||||
|
||||
// Re-seed the embedded OrcaCloud pane if it is already open, so it picks up the new session
|
||||
// without waiting for the next open. A closed pane is seeded when it is next opened.
|
||||
if (WebViewPanel* home = WebViewPanel::if_built())
|
||||
if (home->cloud_mode_active())
|
||||
home->set_cloud_mode(true, cloud_webview_handoff());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -557,6 +557,9 @@ public:
|
||||
void on_update_machine_list(wxCommandEvent& evt);
|
||||
void on_user_login(wxCommandEvent &evt);
|
||||
void on_user_login_handle(wxCommandEvent& evt);
|
||||
// Returns the fragment handoff URL for the signed-in Orca Cloud session, refreshing the
|
||||
// token first if needed. Empty when not signed in.
|
||||
wxString cloud_webview_handoff();
|
||||
void enable_user_preset_folder(bool enable);
|
||||
|
||||
// BBS
|
||||
|
||||
@@ -55,6 +55,13 @@ namespace GUI {
|
||||
|
||||
#define LOGIN_INFO_UPDATE_TIMER_ID 10002
|
||||
|
||||
// OrcaCloud is a native pane of its own: the site refuses to be framed
|
||||
// (x-frame-options: DENY / CSP frame-ancestors 'none').
|
||||
static const char *const kCloudUrl = "https://cloud.orcaslicer.com";
|
||||
// #LeftBoard in resources/web/homepage/css/home.css is a 262px column plus a 1px right
|
||||
// border; the homepage keeps that width as the nav column while the cloud pane is open.
|
||||
static const int kLeftNavWidth = 263;
|
||||
|
||||
// The home page's strings (resources/web/homepage), keyed by their data-i18n and T() ids.
|
||||
static json home_page_strings()
|
||||
{
|
||||
@@ -134,7 +141,11 @@ WebViewPanel::WebViewPanel(wxWindow *parent)
|
||||
m_reset_on_show = WebView::NeedsRecreateOnShow();
|
||||
SetSizer(topsizer);
|
||||
|
||||
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
|
||||
// The homepage shell is the left nav column; when OrcaCloud opens, its page lives in a
|
||||
// second native pane beside it (the site refuses to be framed).
|
||||
m_content_sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
m_content_sizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
|
||||
topsizer->Add(m_content_sizer, wxSizerFlags().Expand().Proportion(1));
|
||||
|
||||
// Log backend information
|
||||
/* m_browser->GetUserAgent() may lead crash
|
||||
@@ -290,12 +301,98 @@ void WebViewPanel::create_browser()
|
||||
m_browser->Hide();
|
||||
}
|
||||
|
||||
// The cloud page gets its own native view, created on first use and kept alive afterwards
|
||||
// so its session and scroll position survive hiding.
|
||||
void WebViewPanel::create_cloud_browser()
|
||||
{
|
||||
m_cloud_browser = WebView::CreateWebView(this, wxEmptyString);
|
||||
|
||||
// Webview events are command events, so they would otherwise propagate up to the
|
||||
// panel's handlers, which drive the homepage view. Keep the cloud pane's events here.
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATING, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_TITLE_CHANGED, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
m_cloud_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebViewPanel::OnCloudWebViewEvent, this);
|
||||
|
||||
m_cloud_browser->Hide();
|
||||
if (m_content_sizer)
|
||||
m_content_sizer->Add(m_cloud_browser, wxSizerFlags().Expand().Proportion(1));
|
||||
}
|
||||
|
||||
// Consumed on the cloud view so it never reaches the homepage handlers; new windows open in
|
||||
// the system browser, the same way PrinterWebView treats them.
|
||||
void WebViewPanel::OnCloudWebViewEvent(wxWebViewEvent& evt)
|
||||
{
|
||||
if (evt.GetEventType() == wxEVT_WEBVIEW_NEWWINDOW) {
|
||||
const wxString url = evt.GetURL();
|
||||
if (!url.empty())
|
||||
wxLaunchDefaultBrowser(url);
|
||||
evt.Veto();
|
||||
}
|
||||
}
|
||||
|
||||
void WebViewPanel::apply_cloud_layout(bool cloud_on)
|
||||
{
|
||||
if (!m_content_sizer)
|
||||
return;
|
||||
|
||||
// Cloud mode pins the homepage to the nav column width; otherwise it fills the panel.
|
||||
if (m_browser) {
|
||||
if (wxSizerItem* item = m_content_sizer->GetItem(m_browser))
|
||||
item->SetProportion(cloud_on ? 0 : 1);
|
||||
// kLeftNavWidth is in DIP: the web view's CSS px are DIP, so scale to physical pixels.
|
||||
m_browser->SetMinSize(cloud_on ? m_browser->FromDIP(wxSize(kLeftNavWidth, -1)) : wxDefaultSize);
|
||||
}
|
||||
Layout();
|
||||
}
|
||||
|
||||
void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
|
||||
{
|
||||
if (on) {
|
||||
if (!m_cloud_browser) {
|
||||
create_cloud_browser();
|
||||
if (url.empty()) {
|
||||
WebView::LoadUrl(m_cloud_browser, wxString(kCloudUrl));
|
||||
} else {
|
||||
// Load the handoff directly: WebView::LoadUrl re-serializes through wxURI, which
|
||||
// could alter the fragment that carries the session.
|
||||
m_cloud_browser->LoadURL(url);
|
||||
}
|
||||
} else if (!url.empty()) {
|
||||
// Re-inject a freshly-refreshed session whenever the pane is (re)opened.
|
||||
m_cloud_browser->LoadURL(url);
|
||||
}
|
||||
m_cloud_browser->Show();
|
||||
} else if (m_cloud_browser) {
|
||||
m_cloud_browser->Hide();
|
||||
// Supabase scrubs with window.location.hash='', so the token fragment can remain in
|
||||
// history; drop it when the pane is closed.
|
||||
m_cloud_browser->ClearHistory();
|
||||
}
|
||||
apply_cloud_layout(on && m_cloud_browser != nullptr);
|
||||
}
|
||||
|
||||
void WebViewPanel::reset_browser()
|
||||
{
|
||||
// Rebuild both views the same way, keeping the cloud pane's open/closed state.
|
||||
const bool cloud_on = m_cloud_browser && m_cloud_browser->IsShown();
|
||||
|
||||
m_browser->Destroy(); // also removes it from the sizer
|
||||
create_browser();
|
||||
GetSizer()->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
|
||||
Layout();
|
||||
m_content_sizer->Insert(size_t(0), m_browser, wxSizerFlags().Expand().Proportion(1));
|
||||
|
||||
if (m_cloud_browser) {
|
||||
m_cloud_browser->Destroy();
|
||||
m_cloud_browser = nullptr;
|
||||
create_cloud_browser();
|
||||
WebView::LoadUrl(m_cloud_browser, kCloudUrl);
|
||||
m_cloud_browser->Show(cloud_on);
|
||||
}
|
||||
|
||||
apply_cloud_layout(cloud_on);
|
||||
}
|
||||
|
||||
bool WebViewPanel::Show(bool show)
|
||||
@@ -645,7 +742,7 @@ void WebViewPanel::update_mode()
|
||||
*/
|
||||
void WebViewPanel::OnNavigationRequest(wxWebViewEvent& evt)
|
||||
{
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
|
||||
const wxString &url = evt.GetURL();
|
||||
if (url.StartsWith("File://") || url.StartsWith("file://")) {
|
||||
if (!url.Contains("/web/homepage/index.html")) {
|
||||
@@ -691,7 +788,7 @@ void WebViewPanel::OnNavigationComplete(wxWebViewEvent& evt)
|
||||
{
|
||||
m_browser->Show();
|
||||
Layout();
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
|
||||
if (wxGetApp().get_mode() == comDevelop)
|
||||
wxLogMessage("%s", "Navigation complete; url='" + evt.GetURL() + "'");
|
||||
UpdateState();
|
||||
@@ -710,6 +807,9 @@ void WebViewPanel::OnDocumentLoaded(wxWebViewEvent& evt)
|
||||
if (wxGetApp().get_mode() == comDevelop)
|
||||
wxLogMessage("%s", "Document loaded; url='" + evt.GetURL() + "'");
|
||||
}
|
||||
// A reloaded homepage loses its cloud-mode class; restore it if the pane is open.
|
||||
if (m_cloud_browser && m_cloud_browser->IsShown())
|
||||
RunScript("SetCloudMode(true)");
|
||||
UpdateState();
|
||||
SendCloudProvidersInfo();
|
||||
}
|
||||
@@ -725,7 +825,7 @@ void WebViewPanel::OnTitleChanged(wxWebViewEvent &evt)
|
||||
*/
|
||||
void WebViewPanel::OnNewWindow(wxWebViewEvent& evt)
|
||||
{
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
|
||||
wxString flag = " (other)";
|
||||
|
||||
if (evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "wx/fs_mem.h"
|
||||
#include "wx/stdpaths.h"
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/tbarbase.h>
|
||||
#include "wx/textctrl.h"
|
||||
#include <wx/timer.h>
|
||||
@@ -114,12 +115,27 @@ public:
|
||||
|
||||
void update_mode();
|
||||
|
||||
// Shows/hides the native OrcaCloud pane beside this homepage nav column. When the pane is
|
||||
// created, or when `on` is true with a non-empty `url`, that URL is loaded (the fragment
|
||||
// carries a Supabase session handoff); `url` empty falls back to the cloud home page.
|
||||
void set_cloud_mode(bool on, const wxString& url = wxString());
|
||||
|
||||
// True while the OrcaCloud pane exists and is visible.
|
||||
bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); }
|
||||
|
||||
bool Show(bool show = true) override;
|
||||
private:
|
||||
void create_browser();
|
||||
void create_cloud_browser();
|
||||
void reset_browser();
|
||||
// Consumes the cloud pane's webview events so they stay off the homepage handlers.
|
||||
void OnCloudWebViewEvent(wxWebViewEvent& evt);
|
||||
// Re-applies the content sizer's proportions and min sizes for the cloud state.
|
||||
void apply_cloud_layout(bool cloud_on);
|
||||
|
||||
wxWebView* m_browser;
|
||||
wxWebView* m_cloud_browser{nullptr};
|
||||
wxBoxSizer* m_content_sizer{nullptr};
|
||||
wxString m_home_url;
|
||||
bool m_reset_on_show{false};
|
||||
wxButton * m_button_stop;
|
||||
|
||||
@@ -418,6 +418,12 @@ bool WebView::DownloadAndInstallWebViewRuntime()
|
||||
return DownloadAndInstallWV2RT() == 0;
|
||||
}
|
||||
#endif
|
||||
wxString WebView::RedactUrl(const wxString& url)
|
||||
{
|
||||
const size_t cut = url.find_first_of(wxString("?#"));
|
||||
return cut == wxString::npos ? url : url.Left(cut);
|
||||
}
|
||||
|
||||
void WebView::LoadUrl(wxWebView * webView, wxString const &url)
|
||||
{
|
||||
auto url2 = url;
|
||||
@@ -425,7 +431,7 @@ void WebView::LoadUrl(wxWebView * webView, wxString const &url)
|
||||
url2.Replace("\\", "/");
|
||||
#endif
|
||||
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << url2.ToUTF8();
|
||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << RedactUrl(url2).ToUTF8();
|
||||
webView->LoadURL(url2);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ public:
|
||||
#endif
|
||||
static void LoadUrl(wxWebView * webView, wxString const &url);
|
||||
|
||||
// Returns the URL truncated at the first '?' or '#', so a query/fragment carrying secrets
|
||||
// (e.g. a session-handoff token) is never written to a log.
|
||||
static wxString RedactUrl(const wxString& url);
|
||||
|
||||
static bool RunScript(wxWebView * webView, wxString const & msg);
|
||||
|
||||
// Marks "wx" as registered so CreateWebView's deferred add skips the duplicate.
|
||||
|
||||
@@ -85,6 +85,8 @@ namespace {
|
||||
constexpr const char* ORCA_DEFAULT_API_URL = "https://api.orcaslicer.com";
|
||||
constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com";
|
||||
constexpr const char* ORCA_DEFAULT_CLOUD_URL = "https://cloud.orcaslicer.com";
|
||||
// Host component of ORCA_DEFAULT_CLOUD_URL. The handoff pins to it (build_webview_handoff_url).
|
||||
constexpr const char* ORCA_DEFAULT_CLOUD_HOST = "cloud.orcaslicer.com";
|
||||
// Orca: This is a public key with no secret, used to identify the client application to the backend.
|
||||
constexpr const char* ORCA_DEFAULT_PUB_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_";
|
||||
|
||||
@@ -3018,6 +3020,82 @@ std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& langua
|
||||
return url;
|
||||
}
|
||||
|
||||
// True when the two strings are equal ignoring ASCII case.
|
||||
static bool ascii_iequals(const std::string& a, const std::string& b)
|
||||
{
|
||||
return a.size() == b.size() &&
|
||||
std::equal(a.begin(), a.end(), b.begin(),
|
||||
[](char x, char y) { return std::tolower(static_cast<unsigned char>(x)) ==
|
||||
std::tolower(static_cast<unsigned char>(y)); });
|
||||
}
|
||||
|
||||
// Percent-encodes every byte outside the RFC 3986 unreserved set (A-Za-z0-9-._~), so token values
|
||||
// survive fragment parsing intact. Deliberately tiny: no URL-encoder dependency.
|
||||
static std::string percent_encode_unreserved(const std::string& s)
|
||||
{
|
||||
static const char* const hex = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0x0F]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string OrcaCloudServiceAgent::build_webview_handoff_url() const
|
||||
{
|
||||
if (cloud_base_url.empty())
|
||||
return "";
|
||||
|
||||
// Pin the handoff to the known cloud origin: https, no userinfo, no port (or :443), and the
|
||||
// exact host. The OrcaCloud pane hardcodes the same host as kCloudUrl in WebViewDialog.cpp, so
|
||||
// the handoff must target that host; if the pane ever becomes configurable, both must move
|
||||
// together. Rejecting anything else avoids handing the refresh token to a plaintext or
|
||||
// attacker-controlled base URL.
|
||||
const std::string https_prefix = "https://";
|
||||
const bool https_scheme = cloud_base_url.size() >= https_prefix.size() &&
|
||||
ascii_iequals(https_prefix, cloud_base_url.substr(0, https_prefix.size()));
|
||||
const std::string authority = https_scheme ? cloud_base_url.substr(https_prefix.size()) : std::string();
|
||||
// host_port = [userinfo@]host[:port], up to the first '/', '?' or '#'.
|
||||
const std::string host_port = authority.substr(0, authority.find_first_of("/?#"));
|
||||
const bool has_userinfo = host_port.find('@') != std::string::npos;
|
||||
const size_t port_sep = host_port.find(':');
|
||||
const std::string host = host_port.substr(0, port_sep);
|
||||
const std::string port = port_sep == std::string::npos ? "" : host_port.substr(port_sep + 1);
|
||||
const bool host_ok = ascii_iequals(host, ORCA_DEFAULT_CLOUD_HOST);
|
||||
const bool port_ok = port.empty() || port == "443";
|
||||
if (!https_scheme || has_userinfo || !host_ok || !port_ok) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "refusing OrcaCloud webview handoff: cloud URL is not the pinned https origin";
|
||||
return "";
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(session_mutex);
|
||||
if (!session.logged_in || session.access_token.empty())
|
||||
return "";
|
||||
|
||||
// Supabase's implicit flow (detectSessionInUrl) requires all four fragment parameters.
|
||||
const auto expires_in = std::max<long long>(0,
|
||||
std::chrono::duration_cast<std::chrono::seconds>(session.expires_at - std::chrono::system_clock::now()).count());
|
||||
|
||||
return cloud_base_url + "/#access_token=" + percent_encode_unreserved(session.access_token) +
|
||||
"&refresh_token=" + percent_encode_unreserved(session.refresh_token) +
|
||||
"&expires_in=" + std::to_string(expires_in) +
|
||||
"&token_type=bearer";
|
||||
}
|
||||
|
||||
bool OrcaCloudServiceAgent::access_token_needs_refresh(std::chrono::seconds skew) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(session_mutex);
|
||||
return should_refresh_locked(skew);
|
||||
}
|
||||
|
||||
int OrcaCloudServiceAgent::get_mw_user_preference(std::function<void(std::string)> callback)
|
||||
{
|
||||
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)";
|
||||
|
||||
@@ -203,6 +203,11 @@ public:
|
||||
// ========================================================================
|
||||
std::string get_cloud_service_host() override;
|
||||
std::string get_cloud_login_url(const std::string& language = "") override;
|
||||
// URL that hands the current session to the OrcaCloud SPA via the URL fragment
|
||||
// (Supabase implicit flow / detectSessionInUrl). Empty when not signed in or no cloud URL.
|
||||
std::string build_webview_handoff_url() const;
|
||||
// True when the access token is missing or will expire within `skew` (no network).
|
||||
bool access_token_needs_refresh(std::chrono::seconds skew) const;
|
||||
int connect_server() override;
|
||||
bool is_server_connected() override;
|
||||
int refresh_connection() override;
|
||||
|
||||
Reference in New Issue
Block a user