diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 294c06fc1..11a921955 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -16,6 +16,8 @@ body: I have checked that this issue has not already been reported. - label: > I am using the latest version of Flow Launcher. + - label: > + I am using the prerelease version of Flow Launcher. - type: textarea attributes: diff --git a/.github/update_release_pr.py b/.github/update_release_pr.py new file mode 100644 index 000000000..ccea511b3 --- /dev/null +++ b/.github/update_release_pr.py @@ -0,0 +1,242 @@ +from os import getenv + +import requests + + +def get_github_prs(token: str, owner: str, repo: str, label: str = "", state: str = "all") -> list[dict]: + """ + Fetches pull requests from a GitHub repository that match a given milestone and label. + + Args: + token (str): GitHub token. + owner (str): The owner of the repository. + repo (str): The name of the repository. + label (str): The label name. Filter is not applied when empty string. + state (str): State of PR, e.g. open, closed, all + + Returns: + list: A list of dictionaries, where each dictionary represents a pull request. + Returns an empty list if no PRs are found or an error occurs. + """ + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + milestone_id = None + milestone_url = f"https://api.github.com/repos/{owner}/{repo}/milestones" + params = {"state": "open"} + + try: + response = requests.get(milestone_url, headers=headers, params=params) + response.raise_for_status() + milestones = response.json() + + if len(milestones) > 2: + print("More than two milestones found, unable to determine the milestone required.") + exit(1) + + # milestones.pop() + for ms in milestones: + if ms["title"] != "Future": + milestone_id = ms["number"] + print(f"Gathering PRs with milestone {ms['title']}...") + break + + if not milestone_id: + print(f"No suitable milestone found in repository '{owner}/{repo}'.") + exit(1) + + except requests.exceptions.RequestException as e: + print(f"Error fetching milestones: {e}") + exit(1) + + # This endpoint allows filtering by milestone and label. A PR in GH's perspective is a type of issue. + prs_url = f"https://api.github.com/repos/{owner}/{repo}/issues" + params = { + "state": state, + "milestone": milestone_id, + "labels": label, + "per_page": 100, + } + + all_prs = [] + page = 1 + while True: + try: + params["page"] = page + response = requests.get(prs_url, headers=headers, params=params) + response.raise_for_status() # Raise an exception for HTTP errors + prs = response.json() + + if not prs: + break # No more PRs to fetch + + # Check for pr key since we are using issues endpoint instead. + all_prs.extend([item for item in prs if "pull_request" in item]) + page += 1 + + except requests.exceptions.RequestException as e: + print(f"Error fetching pull requests: {e}") + exit(1) + + return all_prs + + +def get_prs(pull_request_items: list[dict], label: str = "", state: str = "all") -> list[dict]: + """ + Returns a list of pull requests after applying the label and state filters. + + Args: + pull_request_items (list[dict]): List of PR items. + label (str): The label name. Filter is not applied when empty string. + state (str): State of PR, e.g. open, closed, all + + Returns: + list: A list of dictionaries, where each dictionary represents a pull request. + Returns an empty list if no PRs are found. + """ + pr_list = [] + count = 0 + for pr in pull_request_items: + if state in [pr["state"], "all"] and (not label or [item for item in pr["labels"] if item["name"] == label]): + pr_list.append(pr) + count += 1 + + print(f"Found {count} PRs with {label if label else 'no filter on'} label and state as {state}") + + return pr_list + +def get_prs_assignees(pull_request_items: list[dict], label: str = "", state: str = "all") -> list[str]: + """ + Returns a list of pull request assignees after applying the label and state filters, excludes jjw24. + + Args: + pull_request_items (list[dict]): List of PR items. + label (str): The label name. Filter is not applied when empty string. + state (str): State of PR, e.g. open, closed, all + + Returns: + list: A list of strs, where each string is an assignee name. List is not distinct, so can contain + duplicate names. + Returns an empty list if none are found. + """ + assignee_list = [] + for pr in pull_request_items: + if state in [pr["state"], "all"] and (not label or [item for item in pr["labels"] if item["name"] == label]): + [assignee_list.append(assignee["login"]) for assignee in pr["assignees"] if assignee["login"] != "jjw24" ] + + print(f"Found {len(assignee_list)} assignees with {label if label else 'no filter on'} label and state as {state}") + + return assignee_list + +def get_pr_descriptions(pull_request_items: list[dict]) -> str: + """ + Returns the concatenated string of pr title and number in the format of + '- PR title 1 #3651 + - PR title 2 #3652 + - PR title 3 #3653 + ' + + Args: + pull_request_items (list[dict]): List of PR items. + + Returns: + str: a string of PR titles and numbers + """ + description_content = "" + for pr in pull_request_items: + description_content += f"- {pr['title']} #{pr['number']}\n" + + return description_content + + +def update_pull_request_description(token: str, owner: str, repo: str, pr_number: int, new_description: str) -> None: + """ + Updates the description (body) of a GitHub Pull Request. + + Args: + token (str): Token. + owner (str): The owner of the repository. + repo (str): The name of the repository. + pr_number (int): The number of the pull request to update. + new_description (str): The new content for the PR's description. + + Returns: + dict or None: The updated PR object (as a dictionary) if successful, + None otherwise. + """ + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + } + + url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" + + payload = {"body": new_description} + + print(f"Attempting to update PR #{pr_number} in {owner}/{repo}...") + print(f"URL: {url}") + + try: + response = None + response = requests.patch(url, headers=headers, json=payload) + response.raise_for_status() + + print(f"Successfully updated PR #{pr_number}.") + + except requests.exceptions.RequestException as e: + print(f"Error updating pull request #{pr_number}: {e}") + if response is not None: + print(f"Response status code: {response.status_code}") + print(f"Response text: {response.text}") + exit(1) + + +if __name__ == "__main__": + github_token = getenv("GITHUB_TOKEN") + + if not github_token: + print("Error: GITHUB_TOKEN environment variable not set.") + exit(1) + + repository_owner = "flow-launcher" + repository_name = "flow.launcher" + state = "all" + + print(f"Fetching {state} PRs for {repository_owner}/{repository_name} ...") + + pull_requests = get_github_prs(github_token, repository_owner, repository_name) + + if not pull_requests: + print("No matching pull requests found") + exit(1) + + print(f"\nFound total of {len(pull_requests)} pull requests") + + release_pr = get_prs(pull_requests, "release", "open") + + if len(release_pr) != 1: + print(f"Unable to find the exact release PR. Returned result: {release_pr}") + exit(1) + + print(f"Found release PR: {release_pr[0]['title']}") + + enhancement_prs = get_prs(pull_requests, "enhancement", "closed") + bug_fix_prs = get_prs(pull_requests, "bug", "closed") + + description_content = "# Release notes\n" + description_content += f"## Features\n{get_pr_descriptions(enhancement_prs)}" if enhancement_prs else "" + description_content += f"## Bug fixes\n{get_pr_descriptions(bug_fix_prs)}" if bug_fix_prs else "" + + assignees = list(set(get_prs_assignees(pull_requests, "enhancement", "closed") + get_prs_assignees(pull_requests, "bug", "closed"))) + assignees.sort(key=str.lower) + + description_content += f"### Authors:\n{', '.join(assignees)}" + + update_pull_request_description( + github_token, repository_owner, repository_name, release_pr[0]["number"], description_content + ) + + print(f"PR content updated to:\n{description_content}") diff --git a/.github/workflows/default_plugins.yml b/.github/workflows/default_plugins.yml index 85acafae1..ec8dfcd4e 100644 --- a/.github/workflows/default_plugins.yml +++ b/.github/workflows/default_plugins.yml @@ -3,11 +3,10 @@ name: Publish Default Plugins on: push: branches: ['master'] - paths: ['Plugins/**'] workflow_dispatch: jobs: - build: + publish: runs-on: windows-latest steps: @@ -17,39 +16,24 @@ jobs: with: dotnet-version: 7.0.x - - name: Determine New Plugin Updates - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - browserbookmark: - - 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/plugin.json' - calculator: - - 'Plugins/Flow.Launcher.Plugin.Calculator/plugin.json' - explorer: - - 'Plugins/Flow.Launcher.Plugin.Explorer/plugin.json' - pluginindicator: - - 'Plugins/Flow.Launcher.Plugin.PluginIndicator/plugin.json' - pluginsmanager: - - 'Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json' - processkiller: - - 'Plugins/Flow.Launcher.Plugin.ProcessKiller/plugin.json' - program: - - 'Plugins/Flow.Launcher.Plugin.Program/plugin.json' - shell: - - 'Plugins/Flow.Launcher.Plugin.Shell/plugin.json' - sys: - - 'Plugins/Flow.Launcher.Plugin.Sys/plugin.json' - url: - - 'Plugins/Flow.Launcher.Plugin.Url/plugin.json' - websearch: - - 'Plugins/Flow.Launcher.Plugin.WebSearch/plugin.json' - windowssettings: - - 'Plugins/Flow.Launcher.Plugin.WindowsSettings/plugin.json' - base: 'master' + - name: Update Plugins To Production Version + run: | + $version = "1.0.0" + Get-Content appveyor.yml | ForEach-Object { + if ($_ -match "version:\s*'(\d+\.\d+\.\d+)\.") { + $version = $matches[1] + } + } + + $jsonFiles = Get-ChildItem -Path ".\Plugins\*\plugin.json" + foreach ($file in $jsonFiles) { + $plugin_old_ver = Get-Content $file.FullName -Raw | ConvertFrom-Json + (Get-Content $file) -replace '"Version"\s*:\s*".*?"', "`"Version`": `"$version`"" | Set-Content $file + $plugin_new_ver = Get-Content $file.FullName -Raw | ConvertFrom-Json + Write-Host "Updated" $plugin_old_ver.Name "version from" $plugin_old_ver.Version "to" $plugin_new_ver.Version + } - name: Get BrowserBookmark Version - if: steps.changes.outputs.browserbookmark == 'true' id: updated-version-browserbookmark uses: notiz-dev/github-action-json-property@release with: @@ -57,14 +41,12 @@ jobs: prop_path: 'Version' - name: Build BrowserBookmark - if: steps.changes.outputs.browserbookmark == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.BrowserBookmark" 7z a -tzip "Flow.Launcher.Plugin.BrowserBookmark.zip" "./Flow.Launcher.Plugin.BrowserBookmark/*" rm -r "Flow.Launcher.Plugin.BrowserBookmark" - name: Publish BrowserBookmark - if: steps.changes.outputs.browserbookmark == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.BrowserBookmark" @@ -76,7 +58,6 @@ jobs: - name: Get Calculator Version - if: steps.changes.outputs.calculator == 'true' id: updated-version-calculator uses: notiz-dev/github-action-json-property@release with: @@ -84,14 +65,12 @@ jobs: prop_path: 'Version' - name: Build Calculator - if: steps.changes.outputs.calculator == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Calculator/Flow.Launcher.Plugin.Calculator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Calculator" 7z a -tzip "Flow.Launcher.Plugin.Calculator.zip" "./Flow.Launcher.Plugin.Calculator/*" rm -r "Flow.Launcher.Plugin.Calculator" - name: Publish Calculator - if: steps.changes.outputs.calculator == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Calculator" @@ -103,7 +82,6 @@ jobs: - name: Get Explorer Version - if: steps.changes.outputs.explorer == 'true' id: updated-version-explorer uses: notiz-dev/github-action-json-property@release with: @@ -111,14 +89,12 @@ jobs: prop_path: 'Version' - name: Build Explorer - if: steps.changes.outputs.explorer == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Explorer" 7z a -tzip "Flow.Launcher.Plugin.Explorer.zip" "./Flow.Launcher.Plugin.Explorer/*" rm -r "Flow.Launcher.Plugin.Explorer" - name: Publish Explorer - if: steps.changes.outputs.explorer == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Explorer" @@ -130,7 +106,6 @@ jobs: - name: Get PluginIndicator Version - if: steps.changes.outputs.pluginindicator == 'true' id: updated-version-pluginindicator uses: notiz-dev/github-action-json-property@release with: @@ -138,14 +113,12 @@ jobs: prop_path: 'Version' - name: Build PluginIndicator - if: steps.changes.outputs.pluginindicator == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginIndicator/Flow.Launcher.Plugin.PluginIndicator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginIndicator" 7z a -tzip "Flow.Launcher.Plugin.PluginIndicator.zip" "./Flow.Launcher.Plugin.PluginIndicator/*" rm -r "Flow.Launcher.Plugin.PluginIndicator" - name: Publish PluginIndicator - if: steps.changes.outputs.pluginindicator == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginIndicator" @@ -157,7 +130,6 @@ jobs: - name: Get PluginsManager Version - if: steps.changes.outputs.pluginsmanager == 'true' id: updated-version-pluginsmanager uses: notiz-dev/github-action-json-property@release with: @@ -165,14 +137,12 @@ jobs: prop_path: 'Version' - name: Build PluginsManager - if: steps.changes.outputs.pluginsmanager == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginsManager" 7z a -tzip "Flow.Launcher.Plugin.PluginsManager.zip" "./Flow.Launcher.Plugin.PluginsManager/*" rm -r "Flow.Launcher.Plugin.PluginsManager" - name: Publish PluginsManager - if: steps.changes.outputs.pluginsmanager == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginsManager" @@ -184,7 +154,6 @@ jobs: - name: Get ProcessKiller Version - if: steps.changes.outputs.processkiller == 'true' id: updated-version-processkiller uses: notiz-dev/github-action-json-property@release with: @@ -192,14 +161,12 @@ jobs: prop_path: 'Version' - name: Build ProcessKiller - if: steps.changes.outputs.processkiller == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.ProcessKiller/Flow.Launcher.Plugin.ProcessKiller.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.ProcessKiller" 7z a -tzip "Flow.Launcher.Plugin.ProcessKiller.zip" "./Flow.Launcher.Plugin.ProcessKiller/*" rm -r "Flow.Launcher.Plugin.ProcessKiller" - name: Publish ProcessKiller - if: steps.changes.outputs.processkiller == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.ProcessKiller" @@ -211,7 +178,6 @@ jobs: - name: Get Program Version - if: steps.changes.outputs.program == 'true' id: updated-version-program uses: notiz-dev/github-action-json-property@release with: @@ -219,14 +185,12 @@ jobs: prop_path: 'Version' - name: Build Program - if: steps.changes.outputs.program == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj' --framework net7.0-windows10.0.19041.0 -c Release -o "Flow.Launcher.Plugin.Program" 7z a -tzip "Flow.Launcher.Plugin.Program.zip" "./Flow.Launcher.Plugin.Program/*" rm -r "Flow.Launcher.Plugin.Program" - name: Publish Program - if: steps.changes.outputs.program == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Program" @@ -238,7 +202,6 @@ jobs: - name: Get Shell Version - if: steps.changes.outputs.shell == 'true' id: updated-version-shell uses: notiz-dev/github-action-json-property@release with: @@ -246,14 +209,12 @@ jobs: prop_path: 'Version' - name: Build Shell - if: steps.changes.outputs.shell == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Shell" 7z a -tzip "Flow.Launcher.Plugin.Shell.zip" "./Flow.Launcher.Plugin.Shell/*" rm -r "Flow.Launcher.Plugin.Shell" - name: Publish Shell - if: steps.changes.outputs.shell == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Shell" @@ -265,7 +226,6 @@ jobs: - name: Get Sys Version - if: steps.changes.outputs.sys == 'true' id: updated-version-sys uses: notiz-dev/github-action-json-property@release with: @@ -273,14 +233,12 @@ jobs: prop_path: 'Version' - name: Build Sys - if: steps.changes.outputs.sys == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Sys" 7z a -tzip "Flow.Launcher.Plugin.Sys.zip" "./Flow.Launcher.Plugin.Sys/*" rm -r "Flow.Launcher.Plugin.Sys" - name: Publish Sys - if: steps.changes.outputs.sys == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Sys" @@ -292,7 +250,6 @@ jobs: - name: Get Url Version - if: steps.changes.outputs.url == 'true' id: updated-version-url uses: notiz-dev/github-action-json-property@release with: @@ -300,14 +257,12 @@ jobs: prop_path: 'Version' - name: Build Url - if: steps.changes.outputs.url == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.Url/Flow.Launcher.Plugin.Url.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Url" 7z a -tzip "Flow.Launcher.Plugin.Url.zip" "./Flow.Launcher.Plugin.Url/*" rm -r "Flow.Launcher.Plugin.Url" - name: Publish Url - if: steps.changes.outputs.url == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.Url" @@ -319,7 +274,6 @@ jobs: - name: Get WebSearch Version - if: steps.changes.outputs.websearch == 'true' id: updated-version-websearch uses: notiz-dev/github-action-json-property@release with: @@ -327,14 +281,12 @@ jobs: prop_path: 'Version' - name: Build WebSearch - if: steps.changes.outputs.websearch == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.WebSearch/Flow.Launcher.Plugin.WebSearch.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WebSearch" 7z a -tzip "Flow.Launcher.Plugin.WebSearch.zip" "./Flow.Launcher.Plugin.WebSearch/*" rm -r "Flow.Launcher.Plugin.WebSearch" - name: Publish WebSearch - if: steps.changes.outputs.websearch == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.WebSearch" @@ -346,7 +298,6 @@ jobs: - name: Get WindowsSettings Version - if: steps.changes.outputs.windowssettings == 'true' id: updated-version-windowssettings uses: notiz-dev/github-action-json-property@release with: @@ -354,14 +305,12 @@ jobs: prop_path: 'Version' - name: Build WindowsSettings - if: steps.changes.outputs.windowssettings == 'true' run: | dotnet publish 'Plugins/Flow.Launcher.Plugin.WindowsSettings/Flow.Launcher.Plugin.WindowsSettings.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WindowsSettings" 7z a -tzip "Flow.Launcher.Plugin.WindowsSettings.zip" "./Flow.Launcher.Plugin.WindowsSettings/*" rm -r "Flow.Launcher.Plugin.WindowsSettings" - name: Publish WindowsSettings - if: steps.changes.outputs.windowssettings == 'true' uses: softprops/action-gh-release@v2 with: repository: "Flow-Launcher/Flow.Launcher.Plugin.WindowsSettings" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 000000000..7498262de --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,91 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: Build + +on: + workflow_dispatch: + push: + branches: + - dev + - master + pull_request: + +jobs: + build: + + runs-on: windows-latest + env: + FlowVersion: 1.19.5 + NUGET_CERT_REVOCATION_MODE: offline + BUILD_NUMBER: ${{ github.run_number }} + steps: + - uses: actions/checkout@v4 + - name: Set Flow.Launcher.csproj version + id: update + uses: vers-one/dotnet-project-version-updater@v1.7 + with: + file: | + "**/SolutionAssemblyInfo.cs" + version: ${{ env.FlowVersion }}.${{ env.BUILD_NUMBER }} + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 7.0.x +# cache: true +# cache-dependency-path: | +# Flow.Launcher/packages.lock.json +# Flow.Launcher.Core/packages.lock.json +# Flow.Launcher.Infrastructure/packages.lock.json +# Flow.Launcher.Plugin/packages.lock.json + - name: Install vpk + run: dotnet tool install -g vpk + - name: Restore dependencies + run: nuget restore + - name: Build + run: dotnet build --no-restore -c Release + - name: Initialize Service + run: | + sc config WSearch start= auto # Starts Windows Search service- Needed for running ExplorerTest + net start WSearch + - name: Test + run: dotnet test --no-build --verbosity normal -c Release + - name: Perform post_build tasks + shell: powershell + run: .\Scripts\post_build.ps1 + - name: Upload Plugin Nupkg + uses: actions/upload-artifact@v4 + with: + name: Plugin nupkg + path: | + Output\Release\Flow.Launcher.Plugin.*.nupkg + compression-level: 0 + - name: Upload Setup + uses: actions/upload-artifact@v4 + with: + name: Flow Installer + path: | + Output\Packages\Flow-Launcher-*.exe + compression-level: 0 + - name: Upload Portable Version + uses: actions/upload-artifact@v4 + with: + name: Portable Version + path: | + Output\Packages\Flow-Launcher-Portable.zip + compression-level: 0 + - name: Upload Full Nupkg + uses: actions/upload-artifact@v4 + with: + name: Full nupkg + path: | + Output\Packages\FlowLauncher-*-full.nupkg + + compression-level: 0 + - name: Upload Release Information + uses: actions/upload-artifact@v4 + with: + name: RELEASES + path: | + Output\Packages\RELEASES + compression-level: 0 diff --git a/.github/workflows/release_deploy.yml b/.github/workflows/release_deploy.yml new file mode 100644 index 000000000..9e082b95f --- /dev/null +++ b/.github/workflows/release_deploy.yml @@ -0,0 +1,34 @@ +--- + +name: New Release Deployments +on: + release: + types: [published] + workflow_dispatch: + +jobs: + deploy-website: + runs-on: ubuntu-latest + steps: + - name: Trigger dispatch event for deploying website + run: | + http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.DEPLOY_FLOW_WEBSITE }}" \ + https://api.github.com/repos/Flow-Launcher/flow-launcher.github.io/dispatches \ + -d '{"event_type":"deploy"}') + if [ "$http_status" -ne 204 ]; then echo "Error: Deploy website failed, HTTP status code is $http_status"; exit 1; fi + + publish-chocolatey: + runs-on: ubuntu-latest + steps: + - name: Trigger dispatch event for publishing to Chocolatey + run: | + http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.Publish_Chocolatey }}" \ + https://api.github.com/repos/Flow-Launcher/chocolatey-package/dispatches \ + -d '{"event_type":"publish"}') + if [ "$http_status" -ne 204 ]; then echo "Error: Publish Chocolatey package failed, HTTP status code is $http_status"; exit 1; fi diff --git a/.github/workflows/release_pr.yml b/.github/workflows/release_pr.yml new file mode 100644 index 000000000..451bf386c --- /dev/null +++ b/.github/workflows/release_pr.yml @@ -0,0 +1,25 @@ +name: Update release PR + +on: + pull_request: + types: [opened, reopened, synchronize] + branches: + - master + workflow_dispatch: + +jobs: + update-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run release PR update + env: + GITHUB_TOKEN: ${{ secrets.PR_TOKEN }} + run: | + pip install requests -q + python3 ./.github/update_release_pr.py diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 7aaa9296a..47bd66107 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -41,9 +41,8 @@ on: # tags-ignore: # - "**" pull_request_target: - branches: - - '**' - # - '!l10n_dev' + branches-ignore: + - master tags-ignore: - "**" types: diff --git a/Flow.Launcher.Core/Configuration/Portable.cs b/Flow.Launcher.Core/Configuration/Portable.cs index 069154364..7f02cef09 100644 --- a/Flow.Launcher.Core/Configuration/Portable.cs +++ b/Flow.Launcher.Core/Configuration/Portable.cs @@ -1,28 +1,29 @@ -using Microsoft.Win32; -using Squirrel; -using System; +using System; using System.IO; +using System.Linq; using System.Reflection; using System.Windows; -using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin.SharedCommands; -using System.Linq; using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using Microsoft.Win32; +using Squirrel; namespace Flow.Launcher.Core.Configuration { public class Portable : IPortable { + private static readonly string ClassName = nameof(Portable); + private readonly IPublicAPI API = Ioc.Default.GetRequiredService(); /// /// As at Squirrel.Windows version 1.5.2, UpdateManager needs to be disposed after finish /// /// - private UpdateManager NewUpdateManager() + private static UpdateManager NewUpdateManager() { var applicationFolderName = Constant.ApplicationDirectory .Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None) @@ -51,7 +52,7 @@ namespace Flow.Launcher.Core.Configuration } catch (Exception e) { - Log.Exception("|Portable.DisablePortableMode|Error occurred while disabling portable mode", e); + API.LogException(ClassName, "Error occurred while disabling portable mode", e); } } @@ -75,26 +76,22 @@ namespace Flow.Launcher.Core.Configuration } catch (Exception e) { - Log.Exception("|Portable.EnablePortableMode|Error occurred while enabling portable mode", e); + API.LogException(ClassName, "Error occurred while enabling portable mode", e); } } public void RemoveShortcuts() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu); - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop); - portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop); + portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup); } public void RemoveUninstallerEntry() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.RemoveUninstallerRegistryEntry(); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.RemoveUninstallerRegistryEntry(); } public void MoveUserDataFolder(string fromLocation, string toLocation) @@ -110,12 +107,10 @@ namespace Flow.Launcher.Core.Configuration public void CreateShortcuts() { - using (var portabilityUpdater = NewUpdateManager()) - { - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false); - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false); - portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false); - } + using var portabilityUpdater = NewUpdateManager(); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false); + portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false); } public void CreateUninstallerEntry() @@ -129,18 +124,14 @@ namespace Flow.Launcher.Core.Configuration subKey2.SetValue("DisplayIcon", Path.Combine(Constant.ApplicationDirectory, "app.ico"), RegistryValueKind.String); } - using (var portabilityUpdater = NewUpdateManager()) - { - _ = portabilityUpdater.CreateUninstallerRegistryEntry(); - } + using var portabilityUpdater = NewUpdateManager(); + _ = portabilityUpdater.CreateUninstallerRegistryEntry(); } - internal void IndicateDeletion(string filePathTodelete) + private static void IndicateDeletion(string filePathTodelete) { var deleteFilePath = Path.Combine(filePathTodelete, DataLocation.DeletionIndicatorFile); - using (var _ = File.CreateText(deleteFilePath)) - { - } + using var _ = File.CreateText(deleteFilePath); } /// diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs index 68be746f2..6f3b23e11 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -1,24 +1,32 @@ -using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; -using System; +using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.Http; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { public record CommunityPluginSource(string ManifestFileUrl) { + private static readonly string ClassName = nameof(CommunityPluginSource); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private string latestEtag = ""; private List plugins = new(); - private static JsonSerializerOptions PluginStoreItemSerializationOption = new JsonSerializerOptions() + private static readonly JsonSerializerOptions PluginStoreItemSerializationOption = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; @@ -33,35 +41,49 @@ namespace Flow.Launcher.Core.ExternalPlugins /// public async Task> FetchAsync(CancellationToken token) { - Log.Info(nameof(CommunityPluginSource), $"Loading plugins from {ManifestFileUrl}"); + API.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}"); var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl); request.Headers.Add("If-None-Match", latestEtag); - using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) + try + { + using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) .ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.OK) - { - this.plugins = await response.Content - .ReadFromJsonAsync>(PluginStoreItemSerializationOption, cancellationToken: token) - .ConfigureAwait(false); - this.latestEtag = response.Headers.ETag?.Tag; + if (response.StatusCode == HttpStatusCode.OK) + { + plugins = await response.Content + .ReadFromJsonAsync>(PluginStoreItemSerializationOption, cancellationToken: token) + .ConfigureAwait(false); + latestEtag = response.Headers.ETag?.Tag; - Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}"); - return this.plugins; + API.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrl}"); + return plugins; + } + else if (response.StatusCode == HttpStatusCode.NotModified) + { + API.LogInfo(ClassName, $"Resource {ManifestFileUrl} has not been modified."); + return plugins; + } + else + { + API.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + return null; + } } - else if (response.StatusCode == HttpStatusCode.NotModified) + catch (Exception e) { - Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified."); - return this.plugins; - } - else - { - Log.Warn(nameof(CommunityPluginSource), - $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); - throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) + { + API.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrl}.", e); + } + else + { + API.LogException(ClassName, "Error Occurred", e); + } + return null; } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs index affd7c312..bdc1ad3dd 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { @@ -39,10 +40,14 @@ namespace Flow.Launcher.Core.ExternalPlugins var completedTask = await Task.WhenAny(tasks); if (completedTask.IsCompletedSuccessfully) { - // one of the requests completed successfully; keep its results - // and cancel the remaining http requests. - pluginResults = await completedTask; - cts.Cancel(); + var result = await completedTask; + if (result != null) + { + // one of the requests completed successfully; keep its results + // and cancel the remaining http requests. + pluginResults = result; + cts.Cancel(); + } } tasks.Remove(completedTask); } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs index 451df6147..14796a87a 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs @@ -1,19 +1,20 @@ -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin; -using Flow.Launcher.Plugin.SharedCommands; -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Windows; using System.Windows.Forms; -using Flow.Launcher.Core.Resource; using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Core.ExternalPlugins.Environments { public abstract class AbstractPluginEnvironment { + private static readonly string ClassName = nameof(AbstractPluginEnvironment); + protected readonly IPublicAPI API = Ioc.Default.GetRequiredService(); internal abstract string Language { get; } @@ -42,8 +43,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal IEnumerable Setup() { + // If no plugin is using the language, return empty list if (!PluginMetadataList.Any(o => o.Language.Equals(Language, StringComparison.OrdinalIgnoreCase))) + { return new List(); + } if (!string.IsNullOrEmpty(PluginsSettingsFilePath) && FilesFolders.FileExists(PluginsSettingsFilePath)) { @@ -55,24 +59,55 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } var noRuntimeMessage = string.Format( - InternationalizationManager.Instance.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), + API.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), Language, EnvName, Environment.NewLine ); if (API.ShowMsgBox(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) { - var msg = string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); - string selectedFile; + var msg = string.Format(API.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); - selectedFile = GetFileFromDialog(msg, FileDialogFilter); + var selectedFile = GetFileFromDialog(msg, FileDialogFilter); if (!string.IsNullOrEmpty(selectedFile)) + { PluginsSettingsFilePath = selectedFile; - + } // Nothing selected because user pressed cancel from the file dialog window - if (string.IsNullOrEmpty(selectedFile)) - InstallEnvironment(); + else + { + var forceDownloadMessage = string.Format( + API.GetTranslation("runtimeExecutableInvalidChooseDownload"), + Language, + EnvName, + Environment.NewLine + ); + + // Let users select valid path or choose to download + while (string.IsNullOrEmpty(selectedFile)) + { + if (API.ShowMsgBox(forceDownloadMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes) + { + // Continue select file + selectedFile = GetFileFromDialog(msg, FileDialogFilter); + } + else + { + // User selected no, break the loop + break; + } + } + + if (!string.IsNullOrEmpty(selectedFile)) + { + PluginsSettingsFilePath = selectedFile; + } + else + { + InstallEnvironment(); + } + } } else { @@ -85,8 +120,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } else { - API.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); - Log.Error("PluginsLoader", + API.ShowMsgBox(string.Format(API.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); + API.LogError(ClassName, $"Not able to successfully set {EnvName} path, setting's plugin executable path variable is still an empty string.", $"{Language}Environment"); @@ -98,13 +133,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments private void EnsureLatestInstalled(string expectedPath, string currentPath, string installedDirPath) { - if (expectedPath == currentPath) - return; + if (expectedPath == currentPath) return; FilesFolders.RemoveFolderIfExists(installedDirPath, (s) => API.ShowMsgBox(s)); InstallEnvironment(); - } internal abstract PluginPair CreatePluginPair(string filePath, PluginMetadata metadata); @@ -116,13 +149,16 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments foreach (var metadata in PluginMetadataList) { if (metadata.Language.Equals(languageToSet, StringComparison.OrdinalIgnoreCase)) + { + metadata.AssemblyName = string.Empty; pluginPairs.Add(CreatePluginPair(filePath, metadata)); + } } return pluginPairs; } - private string GetFileFromDialog(string title, string filter = "") + private static string GetFileFromDialog(string title, string filter = "") { var dlg = new OpenFileDialog { @@ -136,7 +172,6 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments var result = dlg.ShowDialog(); return result == DialogResult.OK ? dlg.FileName : string.Empty; - } /// @@ -179,31 +214,33 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments else { if (IsUsingPortablePath(settings.PluginSettings.PythonExecutablePath, DataLocation.PythonEnvironmentName)) + { settings.PluginSettings.PythonExecutablePath = GetUpdatedEnvironmentPath(settings.PluginSettings.PythonExecutablePath); + } if (IsUsingPortablePath(settings.PluginSettings.NodeExecutablePath, DataLocation.NodeEnvironmentName)) + { settings.PluginSettings.NodeExecutablePath = GetUpdatedEnvironmentPath(settings.PluginSettings.NodeExecutablePath); + } } } private static bool IsUsingPortablePath(string filePath, string pluginEnvironmentName) { - if (string.IsNullOrEmpty(filePath)) - return false; + if (string.IsNullOrEmpty(filePath)) return false; // DataLocation.PortableDataPath returns the current portable path, this determines if an out // of date path is also a portable path. - var portableAppEnvLocation = $"UserData\\{DataLocation.PluginEnvironments}\\{pluginEnvironmentName}"; + var portableAppEnvLocation = Path.Combine("UserData", DataLocation.PluginEnvironments, pluginEnvironmentName); return filePath.Contains(portableAppEnvLocation); } private static bool IsUsingRoamingPath(string filePath) { - if (string.IsNullOrEmpty(filePath)) - return false; + if (string.IsNullOrEmpty(filePath)) return false; return filePath.StartsWith(DataLocation.RoamingDataPath); } @@ -213,8 +250,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments var index = filePath.IndexOf(DataLocation.PluginEnvironments); // get the substring after "Environments" because we can not determine it dynamically - var ExecutablePathSubstring = filePath.Substring(index + DataLocation.PluginEnvironments.Count()); - return $"{DataLocation.PluginEnvironmentsPath}{ExecutablePathSubstring}"; + var executablePathSubstring = filePath[(index + DataLocation.PluginEnvironments.Length)..]; + return $"{DataLocation.PluginEnvironmentsPath}{executablePathSubstring}"; } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs index b67059b1b..62d2d3e91 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptEnvironment.cs @@ -4,7 +4,6 @@ using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins.Environments { - internal class JavaScriptEnvironment : TypeScriptEnvironment { internal override string Language => AllowedLanguage.JavaScript; diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs index 6c8c5aa57..726bc4cd4 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/JavaScriptV2Environment.cs @@ -4,7 +4,6 @@ using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins.Environments { - internal class JavaScriptV2Environment : TypeScriptV2Environment { internal override string Language => AllowedLanguage.JavaScriptV2; diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs index 607c19062..455ee096d 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs @@ -1,10 +1,11 @@ -using Droplex; +using System.Collections.Generic; +using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; -using System.Collections.Generic; -using System.IO; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -22,17 +23,23 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string FileDialogFilter => "Python|pythonw.exe"; - internal override string PluginsSettingsFilePath { get => PluginSettings.PythonExecutablePath; set => PluginSettings.PythonExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.PythonExecutablePath; + set => PluginSettings.PythonExecutablePath = value; + } internal PythonEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); // Python 3.11.4 is no longer Windows 7 compatible. If user is on Win 7 and // uses Python plugin they need to custom install and use v3.8.9 - DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs index 399f7cc03..12965286f 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; -using Droplex; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin.SharedCommands; -using Flow.Launcher.Plugin; using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0"); internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe"); - internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.NodeExecutablePath; + set => PluginSettings.NodeExecutablePath = value; + } internal TypeScriptEnvironment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs index e8cb72e11..6960b79c9 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; -using Droplex; -using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin.SharedCommands; -using Flow.Launcher.Plugin; using System.IO; +using Droplex; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.ExternalPlugins.Environments { @@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0"); internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe"); - internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; } + internal override string PluginsSettingsFilePath + { + get => PluginSettings.NodeExecutablePath; + set => PluginSettings.NodeExecutablePath = value; + } internal TypeScriptV2Environment(List pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { } + private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext()); + internal override void InstallEnvironment() { FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); PluginsSettingsFilePath = ExecutablePath; } diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index ac8abcdcc..7ca91eaec 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,13 +1,16 @@ -using Flow.Launcher.Infrastructure.Logger; -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.ExternalPlugins { public static class PluginsManifest { + private static readonly string ClassName = nameof(PluginsManifest); + private static readonly CommunityPluginStore mainPluginStore = new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json", "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", @@ -17,11 +20,11 @@ namespace Flow.Launcher.Core.ExternalPlugins private static readonly SemaphoreSlim manifestUpdateLock = new(1); private static DateTime lastFetchedAt = DateTime.MinValue; - private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) + public static async Task UpdateManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) { try { @@ -43,7 +46,7 @@ namespace Flow.Launcher.Core.ExternalPlugins } catch (Exception e) { - Log.Exception($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http request failed", e); + Ioc.Default.GetRequiredService().LogException(ClassName, "Http request failed", e); } finally { diff --git a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs b/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs deleted file mode 100644 index 79d6d7605..000000000 --- a/Flow.Launcher.Core/ExternalPlugins/UserPlugin.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; - -namespace Flow.Launcher.Core.ExternalPlugins -{ - public record UserPlugin - { - public string ID { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Author { get; set; } - public string Version { get; set; } - public string Language { get; set; } - public string Website { get; set; } - public string UrlDownload { get; set; } - public string UrlSourceCode { get; set; } - public string LocalInstallPath { get; set; } - public string IcoPath { get; set; } - public DateTime? LatestReleaseDate { get; set; } - public DateTime? DateAdded { get; set; } - - public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath); - } -} diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs index 97c3c8981..b19bb6c79 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPlugin.cs @@ -1,28 +1,14 @@ -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Infrastructure; -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin; using Microsoft.IO; -using System.Windows; -using System.Windows.Controls; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using CheckBox = System.Windows.Controls.CheckBox; -using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; -using System.Windows.Documents; namespace Flow.Launcher.Core.Plugin { @@ -32,7 +18,9 @@ namespace Flow.Launcher.Core.Plugin /// internal abstract class JsonRPCPlugin : JsonRPCPluginBase { - public const string JsonRPC = "JsonRPC"; + public new const string JsonRPC = "JsonRPC"; + + private static readonly string ClassName = nameof(JsonRPCPlugin); protected abstract Task RequestAsync(JsonRPCRequestModel rpcRequest, CancellationToken token = default); protected abstract string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default); @@ -41,9 +29,6 @@ namespace Flow.Launcher.Core.Plugin private int RequestId { get; set; } - private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); - private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name, "Settings.json"); - public override List LoadContextMenus(Result selectedResult) { var request = new JsonRPCRequestModel(RequestId++, @@ -69,13 +54,6 @@ namespace Flow.Launcher.Core.Plugin } }; - private static readonly JsonSerializerOptions settingSerializeOption = new() - { - WriteIndented = true - }; - - private readonly Dictionary _settingControls = new(); - private async Task> DeserializedResultAsync(Stream output) { await using (output) @@ -134,7 +112,6 @@ namespace Flow.Launcher.Core.Plugin return !result.JsonRPCAction.DontHideAfterAction; } - /// /// Execute external program and return the output /// @@ -172,11 +149,11 @@ namespace Flow.Launcher.Core.Plugin var error = standardError.ReadToEnd(); if (!string.IsNullOrEmpty(error)) { - Log.Error($"|JsonRPCPlugin.Execute|{error}"); + Context.API.LogError(ClassName, error); return string.Empty; } - Log.Error("|JsonRPCPlugin.Execute|Empty standard output and standard error."); + Context.API.LogError(ClassName, "Empty standard output and standard error."); return string.Empty; } @@ -184,8 +161,8 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception( - $"|JsonRPCPlugin.Execute|Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>", + Context.API.LogException(ClassName, + $"Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>", e); return string.Empty; } @@ -196,7 +173,7 @@ namespace Flow.Launcher.Core.Plugin using var process = Process.Start(startInfo); if (process == null) { - Log.Error("|JsonRPCPlugin.ExecuteAsync|Can't start new process"); + Context.API.LogError(ClassName, "Can't start new process"); return Stream.Null; } @@ -216,7 +193,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception("|JsonRPCPlugin.ExecuteAsync|Exception when kill process", e); + Context.API.LogException(ClassName, "Exception when kill process", e); } }); @@ -237,7 +214,7 @@ namespace Flow.Launcher.Core.Plugin { case (0, 0): const string errorMessage = "Empty JSON-RPC Response."; - Log.Warn($"|{nameof(JsonRPCPlugin)}.{nameof(ExecuteAsync)}|{errorMessage}"); + Context.API.LogWarn(ClassName, errorMessage); break; case (_, not 0): throw new InvalidDataException(Encoding.UTF8.GetString(errorBuffer.ToArray())); // The process has exited with an error message diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs index 7248c6259..df0438409 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs @@ -1,32 +1,15 @@ -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Infrastructure; -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin; -using Microsoft.IO; -using System.Windows; -using System.Windows.Controls; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -using CheckBox = System.Windows.Controls.CheckBox; using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; -using System.Windows.Documents; -using static System.Windows.Forms.LinkLabel; -using Droplex; -using System.Windows.Forms; -using Microsoft.VisualStudio.Threading; namespace Flow.Launcher.Core.Plugin { @@ -36,16 +19,14 @@ namespace Flow.Launcher.Core.Plugin /// public abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable { - protected PluginInitContext Context; public const string JsonRPC = "JsonRPC"; - private int RequestId { get; set; } + protected PluginInitContext Context; private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml"); - private string SettingDirectory => Path.Combine(DataLocation.PluginSettingsDirectory, - Context.CurrentPluginMetadata.Name); + private string SettingDirectory => Context.CurrentPluginMetadata.PluginSettingsDirectoryPath; private string SettingPath => Path.Combine(SettingDirectory, "Settings.json"); @@ -125,7 +106,6 @@ namespace Flow.Launcher.Core.Plugin public abstract Task> QueryAsync(Query query, CancellationToken token); - private async Task InitSettingAsync() { JsonRpcConfigurationModel configuration = null; @@ -137,7 +117,6 @@ namespace Flow.Launcher.Core.Plugin await File.ReadAllTextAsync(SettingConfigurationPath)); } - Settings ??= new JsonRPCPluginSettings { Configuration = configuration, SettingPath = SettingPath, API = Context.API @@ -148,7 +127,7 @@ namespace Flow.Launcher.Core.Plugin public virtual async Task InitAsync(PluginInitContext context) { - this.Context = context; + Context = context; await InitSettingAsync(); } @@ -166,13 +145,5 @@ namespace Flow.Launcher.Core.Plugin { return Settings.CreateSettingPanel(); } - - public void DeletePluginSettingsDirectory() - { - if (Directory.Exists(SettingDirectory)) - { - Directory.Delete(SettingDirectory, true); - } - } } } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs index 8412ba7e8..003e72a5d 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -1,18 +1,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; -using System.Windows.Forms; using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; -using CheckBox = System.Windows.Controls.CheckBox; -using ComboBox = System.Windows.Controls.ComboBox; -using Control = System.Windows.Controls.Control; -using Orientation = System.Windows.Controls.Orientation; -using TextBox = System.Windows.Controls.TextBox; -using UserControl = System.Windows.Controls.UserControl; + +#nullable enable namespace Flow.Launcher.Core.Plugin { @@ -23,51 +19,74 @@ namespace Flow.Launcher.Core.Plugin public required string SettingPath { get; init; } public Dictionary SettingControls { get; } = new(); - public IReadOnlyDictionary Inner => Settings; - protected ConcurrentDictionary Settings { get; set; } + public IReadOnlyDictionary Inner => Settings; + protected ConcurrentDictionary Settings { get; set; } = null!; public required IPublicAPI API { get; init; } - private JsonStorage> _storage; + private static readonly string ClassName = nameof(JsonRPCPluginSettings); - // maybe move to resource? - private static readonly Thickness settingControlMargin = new(0, 9, 18, 9); - private static readonly Thickness settingCheckboxMargin = new(0, 9, 9, 9); - private static readonly Thickness settingPanelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingTextBlockMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelPanelMargin = new(70, 9, 18, 9); - private static readonly Thickness settingLabelMargin = new(0, 0, 0, 0); - private static readonly Thickness settingDescMargin = new(0, 2, 0, 0); - private static readonly Thickness settingSepMargin = new(0, 0, 0, 2); + private JsonStorage> _storage = null!; + + private static readonly Thickness SettingPanelMargin = (Thickness)Application.Current.FindResource("SettingPanelMargin"); + private static readonly Thickness SettingPanelItemLeftMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftMargin"); + private static readonly Thickness SettingPanelItemTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemTopBottomMargin"); + private static readonly Thickness SettingPanelItemLeftTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftTopBottomMargin"); + private static readonly double SettingPanelTextBoxMinWidth = (double)Application.Current.FindResource("SettingPanelTextBoxMinWidth"); + private static readonly double SettingPanelPathTextBoxWidth = (double)Application.Current.FindResource("SettingPanelPathTextBoxWidth"); + private static readonly double SettingPanelAreaTextBoxMinHeight = (double)Application.Current.FindResource("SettingPanelAreaTextBoxMinHeight"); public async Task InitializeAsync() { - _storage = new JsonStorage>(SettingPath); - Settings = await _storage.LoadAsync(); - - if (Configuration == null) + if (Settings == null) { - return; + _storage = new JsonStorage>(SettingPath); + Settings = await _storage.LoadAsync(); + + // Because value type of settings dictionary is object which causes them to be JsonElement when loading from json files, + // we need to convert it to the correct type + foreach (var (key, value) in Settings) + { + if (value is not JsonElement jsonElement) continue; + + Settings[key] = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString() ?? value, + JsonValueKind.True => jsonElement.GetBoolean(), + JsonValueKind.False => jsonElement.GetBoolean(), + JsonValueKind.Null => null, + _ => value + }; + } } + if (Configuration == null) return; + foreach (var (type, attributes) in Configuration.Body) { - if (attributes.Name == null) - { - continue; - } + // Skip if the setting does not have attributes or name + if (attributes?.Name == null) continue; - if (!Settings.ContainsKey(attributes.Name)) + // Skip if the setting does not have attributes or name + if (!NeedSaveInSettings(type)) continue; + + // If need save in settings, we need to make sure the setting exists in the settings file + if (Settings.ContainsKey(attributes.Name)) continue; + + if (type == "checkbox") + { + // If can parse the default value to bool, use it, otherwise use false + Settings[attributes.Name] = bool.TryParse(attributes.DefaultValue, out var value) && value; + } + else { Settings[attributes.Name] = attributes.DefaultValue; } } } - public void UpdateSettings(IReadOnlyDictionary settings) { - if (settings == null || settings.Count == 0) - return; + if (settings == null || settings.Count == 0) return; foreach (var (key, value) in settings) { @@ -78,19 +97,23 @@ namespace Flow.Launcher.Core.Plugin switch (control) { case TextBox textBox: - textBox.Dispatcher.Invoke(() => textBox.Text = value as string ?? string.Empty); + var text = value as string ?? string.Empty; + textBox.Dispatcher.Invoke(() => textBox.Text = text); break; case PasswordBox passwordBox: - passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string ?? string.Empty); + var password = value as string ?? string.Empty; + passwordBox.Dispatcher.Invoke(() => passwordBox.Password = password); break; case ComboBox comboBox: comboBox.Dispatcher.Invoke(() => comboBox.SelectedItem = value); break; case CheckBox checkBox: - checkBox.Dispatcher.Invoke(() => - checkBox.IsChecked = value is bool isChecked - ? isChecked - : bool.Parse(value as string ?? string.Empty)); + var isChecked = value is bool boolValue + ? boolValue + // If can parse the default value to bool, use it, otherwise use false + : value is string stringValue && bool.TryParse(stringValue, out var boolValueFromString) + && boolValueFromString; + checkBox.Dispatcher.Invoke(() => checkBox.IsChecked = isChecked); break; } } @@ -101,14 +124,28 @@ namespace Flow.Launcher.Core.Plugin public async Task SaveAsync() { - await _storage.SaveAsync(); + try + { + await _storage.SaveAsync(); + } + catch (System.Exception e) + { + API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e); + } } public void Save() { - _storage.Save(); + try + { + _storage.Save(); + } + catch (System.Exception e) + { + API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e); + } } - + public bool NeedCreateSettingPanel() { // If there are no settings or the settings configuration is empty, return null @@ -117,330 +154,350 @@ namespace Flow.Launcher.Core.Plugin public Control CreateSettingPanel() { - // No need to check if NeedCreateSettingPanel is true because CreateSettingPanel will only be called if it's true + if (!NeedCreateSettingPanel()) return null!; - var settingWindow = new UserControl(); - var mainPanel = new Grid { Margin = settingPanelMargin, VerticalAlignment = VerticalAlignment.Center }; - - ColumnDefinition gridCol1 = new ColumnDefinition(); - ColumnDefinition gridCol2 = new ColumnDefinition(); - - gridCol1.Width = new GridLength(70, GridUnitType.Star); - gridCol2.Width = new GridLength(30, GridUnitType.Star); - mainPanel.ColumnDefinitions.Add(gridCol1); - mainPanel.ColumnDefinitions.Add(gridCol2); - settingWindow.Content = mainPanel; - int rowCount = 0; - - foreach (var (type, attribute) in Configuration.Body) + // Create main grid with two columns (Column 1: Auto, Column 2: *) + var mainPanel = new Grid { Margin = SettingPanelMargin, VerticalAlignment = VerticalAlignment.Center }; + mainPanel.ColumnDefinitions.Add(new ColumnDefinition() { - Separator sep = new Separator(); - sep.VerticalAlignment = VerticalAlignment.Top; - sep.Margin = settingSepMargin; - sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */ - var panel = new StackPanel + Width = new GridLength(0, GridUnitType.Auto) + }); + mainPanel.ColumnDefinitions.Add(new ColumnDefinition() + { + Width = new GridLength(1, GridUnitType.Star) + }); + + // Iterate over each setting and create one row for it + var rowCount = 0; + foreach (var (type, attributes) in Configuration!.Body) + { + // Skip if the setting does not have attributes or name + if (attributes?.Name == null) continue; + + // Add a new row to the main grid + mainPanel.RowDefinitions.Add(new RowDefinition() { - Orientation = Orientation.Vertical, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelPanelMargin - }; - - RowDefinition gridRow = new RowDefinition(); - mainPanel.RowDefinitions.Add(gridRow); - var name = new TextBlock() - { - Text = attribute.Label, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingLabelMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - - var desc = new TextBlock() - { - Text = attribute.Description, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - Margin = settingDescMargin, - TextWrapping = TextWrapping.WrapWithOverflow - }; - - desc.SetResourceReference(TextBlock.ForegroundProperty, "Color04B"); - - if (attribute.Description == null) /* if no description, hide */ - desc.Visibility = Visibility.Collapsed; - - - if (type != "textBlock") /* if textBlock, hide desc */ - { - panel.Children.Add(name); - panel.Children.Add(desc); - } - - - Grid.SetColumn(panel, 0); - Grid.SetRow(panel, rowCount); + Height = new GridLength(0, GridUnitType.Auto) + }); + // State controls for column 0 and 1 + StackPanel? panel = null; FrameworkElement contentControl; + // If the type is textBlock, separator, or checkbox, we do not need to create a panel + if (type != "textBlock" && type != "separator" && type != "checkbox") + { + // Create a panel to hold the label and description + panel = new StackPanel + { + Margin = SettingPanelItemTopBottomMargin, + Orientation = Orientation.Vertical, + VerticalAlignment = VerticalAlignment.Center + }; + + // Create a text block for name + var name = new TextBlock() + { + Text = attributes.Label, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + // Create a text block for description + TextBlock? desc = null; + if (attributes.Description != null) + { + desc = new TextBlock() + { + Text = attributes.Description, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.WrapWithOverflow + }; + + desc.SetResourceReference(TextBlock.StyleProperty, "SettingPanelTextBlockDescriptionStyle"); // for theme change + } + + // Add the name and description to the panel + panel.Children.Add(name); + if (desc != null) panel.Children.Add(desc); + } + switch (type) { case "textBlock": - { - contentControl = new TextBlock { - Text = attribute.Description.Replace("\\r\\n", "\r\n"), - Margin = settingTextBlockMargin, - Padding = new Thickness(0, 0, 0, 0), - HorizontalAlignment = System.Windows.HorizontalAlignment.Left, - TextAlignment = TextAlignment.Left, - TextWrapping = TextWrapping.Wrap - }; + contentControl = new TextBlock + { + Text = attributes.Description?.Replace("\\r\\n", "\r\n") ?? string.Empty, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + }; - Grid.SetColumn(contentControl, 0); - Grid.SetColumnSpan(contentControl, 2); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); - - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "input": - { - var textBox = new TextBox() { - Text = Settings[attribute.Name] as string ?? string.Empty, - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; + var textBox = new TextBox() + { + MinWidth = SettingPanelTextBoxMinWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description + }; - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; + textBox.TextChanged += (_, _) => + { + Settings[attributes.Name] = textBox.Text; + }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = textBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "inputWithFileBtn": case "inputWithFolderBtn": - { - var textBox = new TextBox() { - Margin = new Thickness(10, 0, 0, 0), - Text = Settings[attribute.Name] as string ?? string.Empty, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; - - textBox.TextChanged += (_, _) => - { - Settings[attribute.Name] = textBox.Text; - }; - - var Btn = new System.Windows.Controls.Button() - { - Margin = new Thickness(10, 0, 0, 0), Content = "Browse" - }; - - Btn.Click += (_, _) => - { - using CommonDialog dialog = type switch + var textBox = new TextBox() { - "inputWithFolderBtn" => new FolderBrowserDialog(), - _ => new OpenFileDialog(), + Width = SettingPanelPathTextBoxWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftMargin, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description }; - if (dialog.ShowDialog() != DialogResult.OK) return; - var path = dialog switch + textBox.TextChanged += (_, _) => { - FolderBrowserDialog folderDialog => folderDialog.SelectedPath, - OpenFileDialog fileDialog => fileDialog.FileName, + Settings[attributes.Name] = textBox.Text; }; - textBox.Text = path; - Settings[attribute.Name] = path; - }; - var dockPanel = new DockPanel() { Margin = settingControlMargin }; + var Btn = new Button() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftMargin, + Content = API.GetTranslation("select") + }; - DockPanel.SetDock(Btn, Dock.Right); - dockPanel.Children.Add(Btn); - dockPanel.Children.Add(textBox); - contentControl = dockPanel; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + Btn.Click += (_, _) => + { + using System.Windows.Forms.CommonDialog dialog = type switch + { + "inputWithFolderBtn" => new System.Windows.Forms.FolderBrowserDialog(), + _ => new System.Windows.Forms.OpenFileDialog(), + }; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); + if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + { + return; + } - break; - } + var path = dialog switch + { + System.Windows.Forms.FolderBrowserDialog folderDialog => folderDialog.SelectedPath, + System.Windows.Forms.OpenFileDialog fileDialog => fileDialog.FileName, + _ => throw new System.NotImplementedException() + }; + + textBox.Text = path; + Settings[attributes.Name] = path; + }; + + var stackPanel = new StackPanel() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + Orientation = Orientation.Horizontal + }; + + // Create a stack panel to wrap the button and text box + stackPanel.Children.Add(textBox); + stackPanel.Children.Add(Btn); + + contentControl = stackPanel; + + break; + } case "textarea": - { - var textBox = new TextBox() { - Height = 120, - Margin = settingControlMargin, - VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.WrapWithOverflow, - AcceptsReturn = true, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - Text = Settings[attribute.Name] as string ?? string.Empty, - ToolTip = attribute.Description - }; + var textBox = new TextBox() + { + MinHeight = SettingPanelAreaTextBoxMinHeight, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + TextWrapping = TextWrapping.WrapWithOverflow, + AcceptsReturn = true, + Text = Settings[attributes.Name] as string ?? string.Empty, + ToolTip = attributes.Description + }; - textBox.TextChanged += (sender, _) => - { - Settings[attribute.Name] = ((TextBox)sender).Text; - }; + textBox.TextChanged += (sender, _) => + { + Settings[attributes.Name] = ((TextBox)sender).Text; + }; - contentControl = textBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = textBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "passwordBox": - { - var passwordBox = new PasswordBox() { - Margin = settingControlMargin, - Password = Settings[attribute.Name] as string ?? string.Empty, - PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar, - HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch, - ToolTip = attribute.Description - }; + var passwordBox = new PasswordBox() + { + MinWidth = SettingPanelTextBoxMinWidth, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + Password = Settings[attributes.Name] as string ?? string.Empty, + PasswordChar = attributes.passwordChar == default ? '*' : attributes.passwordChar, + ToolTip = attributes.Description, + }; - passwordBox.PasswordChanged += (sender, _) => - { - Settings[attribute.Name] = ((PasswordBox)sender).Password; - }; + passwordBox.PasswordChanged += (sender, _) => + { + Settings[attributes.Name] = ((PasswordBox)sender).Password; + }; - contentControl = passwordBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = passwordBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "dropdown": - { - var comboBox = new System.Windows.Controls.ComboBox() { - ItemsSource = attribute.Options, - SelectedItem = Settings[attribute.Name], - Margin = settingControlMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; + var comboBox = new ComboBox() + { + ItemsSource = attributes.Options, + SelectedItem = Settings[attributes.Name], + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + ToolTip = attributes.Description + }; - comboBox.SelectionChanged += (sender, _) => - { - Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem; - }; + comboBox.SelectionChanged += (sender, _) => + { + Settings[attributes.Name] = (string)((ComboBox)sender).SelectedItem; + }; - contentControl = comboBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = comboBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; - } + break; + } case "checkbox": - var checkBox = new CheckBox { - IsChecked = - Settings[attribute.Name] is bool isChecked + // If can parse the default value to bool, use it, otherwise use false + var defaultValue = bool.TryParse(attributes.DefaultValue, out var value) && value; + var checkBox = new CheckBox + { + IsChecked = + Settings[attributes.Name] is bool isChecked ? isChecked - : bool.Parse(attribute.DefaultValue), - Margin = settingCheckboxMargin, - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - ToolTip = attribute.Description - }; + : defaultValue, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemTopBottomMargin, + Content = attributes.Label, + ToolTip = attributes.Description + }; - checkBox.Click += (sender, _) => - { - Settings[attribute.Name] = ((CheckBox)sender).IsChecked; - }; + checkBox.Click += (sender, _) => + { + Settings[attributes.Name] = ((CheckBox)sender).IsChecked ?? defaultValue; + }; - contentControl = checkBox; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + contentControl = checkBox; - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); - - break; + break; + } case "hyperlink": - var hyperlink = new Hyperlink { ToolTip = attribute.Description, NavigateUri = attribute.url }; - - var linkbtn = new System.Windows.Controls.Button { - HorizontalAlignment = System.Windows.HorizontalAlignment.Right, - Margin = settingControlMargin - }; + var hyperlink = new Hyperlink + { + ToolTip = attributes.Description, + NavigateUri = attributes.url + }; - linkbtn.Content = attribute.urlLabel; + hyperlink.Inlines.Add(attributes.urlLabel); + hyperlink.RequestNavigate += (sender, e) => + { + API.OpenUrl(e.Uri); + e.Handled = true; + }; - contentControl = linkbtn; - Grid.SetColumn(contentControl, 1); - Grid.SetRow(contentControl, rowCount); - if (rowCount != 0) - mainPanel.Children.Add(sep); + var textBlock = new TextBlock() + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = SettingPanelItemLeftTopBottomMargin, + TextAlignment = TextAlignment.Left, + TextWrapping = TextWrapping.Wrap + }; + textBlock.Inlines.Add(hyperlink); - Grid.SetRow(sep, rowCount); - Grid.SetColumn(sep, 0); - Grid.SetColumnSpan(sep, 2); + contentControl = textBlock; - break; + break; + } + case "separator": + { + var sep = new Separator(); + + sep.SetResourceReference(Separator.StyleProperty, "SettingPanelSeparatorStyle"); + + contentControl = sep; + + break; + } default: continue; } - if (type != "textBlock") - SettingControls[attribute.Name] = contentControl; + // If type is textBlock or separator, we just add the content control to the main grid + if (panel == null) + { + // Add the content control to the column 0, row rowCount and columnSpan 2 of the main grid + mainPanel.Children.Add(contentControl); + Grid.SetColumn(contentControl, 0); + Grid.SetColumnSpan(contentControl, 2); + Grid.SetRow(contentControl, rowCount); + } + else + { + // Add the panel to the column 0 and row rowCount of the main grid + mainPanel.Children.Add(panel); + Grid.SetColumn(panel, 0); + Grid.SetRow(panel, rowCount); + + // Add the content control to the column 1 and row rowCount of the main grid + mainPanel.Children.Add(contentControl); + Grid.SetColumn(contentControl, 1); + Grid.SetRow(contentControl, rowCount); + } + + // Add into SettingControls for settings storage if need + if (NeedSaveInSettings(type)) SettingControls[attributes.Name] = contentControl; - mainPanel.Children.Add(panel); - mainPanel.Children.Add(contentControl); rowCount++; } - return settingWindow; + // Wrap the main grid in a user control + return new UserControl() + { + Content = mainPanel + }; + } + + private static bool NeedSaveInSettings(string type) + { + return type != "textBlock" && type != "separator" && type != "hyperlink"; } } } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs index abe563c14..148fd969e 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginV2.cs @@ -10,20 +10,20 @@ using Microsoft.VisualStudio.Threading; using StreamJsonRpc; using IAsyncDisposable = System.IAsyncDisposable; - namespace Flow.Launcher.Core.Plugin { internal abstract class JsonRPCPluginV2 : JsonRPCPluginBase, IAsyncDisposable, IAsyncReloadable, IResultUpdated { public const string JsonRpc = "JsonRPC"; + private static readonly string ClassName = nameof(JsonRPCPluginV2); + protected abstract IDuplexPipe ClientPipe { get; set; } protected StreamReader ErrorStream { get; set; } private JsonRpc RPC { get; set; } - protected override async Task ExecuteResultAsync(JsonRPCResult result) { var res = await RPC.InvokeAsync(result.JsonRPCAction.Method, @@ -55,7 +55,6 @@ namespace Flow.Launcher.Core.Plugin return results; } - public override async Task InitAsync(PluginInitContext context) { await base.InitAsync(context); @@ -88,7 +87,6 @@ namespace Flow.Launcher.Core.Plugin protected abstract MessageHandlerType MessageHandler { get; } - private void SetupJsonRPC() { var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RequestSerializeOption }; @@ -118,8 +116,17 @@ namespace Flow.Launcher.Core.Plugin { await RPC.InvokeAsync("reload_data", Context); } - catch (RemoteMethodNotFoundException e) + catch (RemoteMethodNotFoundException) { + // Ignored + } + catch (ConnectionLostException) + { + // Ignored + } + catch (Exception e) + { + Context.API.LogException(ClassName, $"Failed to call reload_data for plugin {Context.CurrentPluginMetadata.Name}", e); } } @@ -129,8 +136,17 @@ namespace Flow.Launcher.Core.Plugin { await RPC.InvokeAsync("close"); } - catch (RemoteMethodNotFoundException e) + catch (RemoteMethodNotFoundException) { + // Ignored + } + catch (ConnectionLostException) + { + // Ignored + } + catch (Exception e) + { + Context.API.LogException(ClassName, $"Failed to call close for plugin {Context.CurrentPluginMetadata.Name}", e); } finally { diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs index 8df2ce9ed..4d988b837 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs @@ -12,7 +12,7 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models { public class JsonRPCPublicAPI { - private IPublicAPI _api; + private readonly IPublicAPI _api; public JsonRPCPublicAPI(IPublicAPI api) { @@ -104,7 +104,6 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models return _api.GetAllPlugins(); } - public MatchResult FuzzySearch(string query, string stringToCompare) { return _api.FuzzySearch(query, stringToCompare); @@ -156,6 +155,11 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models _api.LogWarn(className, message, methodName); } + public void LogError(string className, string message, [CallerMemberName] string methodName = "") + { + _api.LogError(className, message, methodName); + } + public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null) { _api.OpenDirectory(DirectoryPath, FileNameOrFilePath); @@ -185,5 +189,10 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models { _api.StopLoadingBar(); } + + public void SavePluginCaches() + { + _api.SavePluginCaches(); + } } } diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index dd6517a7f..f7457b4e1 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -1,17 +1,22 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.IO; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Plugin; using System.Text.Json; +using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Plugin { - internal abstract class PluginConfig { + private static readonly string ClassName = nameof(PluginConfig); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + /// /// Parse plugin metadata in the given directories /// @@ -33,7 +38,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception($"|PluginConfig.ParsePLuginConfigs|Can't delete <{directory}>", e); + API.LogException(ClassName, $"Can't delete <{directory}>", e); } } else @@ -50,11 +55,11 @@ namespace Flow.Launcher.Core.Plugin duplicateList .ForEach( - x => Log.Warn("PluginConfig", - string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " + - "not loaded due to version not the highest of the duplicates", - x.Name, x.ID, x.Version), - "GetUniqueLatestPluginMetadata")); + x => API.LogWarn(ClassName, + string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " + + "not loaded due to version not the highest of the duplicates", + x.Name, x.ID, x.Version), + "GetUniqueLatestPluginMetadata")); return uniqueList; } @@ -102,7 +107,7 @@ namespace Flow.Launcher.Core.Plugin string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName); if (!File.Exists(configPath)) { - Log.Error($"|PluginConfig.GetPluginMetadata|Didn't find config file <{configPath}>"); + API.LogError(ClassName, $"Didn't find config file <{configPath}>"); return null; } @@ -112,29 +117,29 @@ namespace Flow.Launcher.Core.Plugin metadata = JsonSerializer.Deserialize(File.ReadAllText(configPath)); metadata.PluginDirectory = pluginDirectory; // for plugins which doesn't has ActionKeywords key - metadata.ActionKeywords = metadata.ActionKeywords ?? new List { metadata.ActionKeyword }; + metadata.ActionKeywords ??= new List { metadata.ActionKeyword }; // for plugin still use old ActionKeyword metadata.ActionKeyword = metadata.ActionKeywords?[0]; } catch (Exception e) { - Log.Exception($"|PluginConfig.GetPluginMetadata|invalid json for config <{configPath}>", e); + API.LogException(ClassName, $"Invalid json for config <{configPath}>", e); return null; } if (!AllowedLanguage.IsAllowed(metadata.Language)) { - Log.Error($"|PluginConfig.GetPluginMetadata|Invalid language <{metadata.Language}> for config <{configPath}>"); + API.LogError(ClassName, $"Invalid language <{metadata.Language}> for config <{configPath}>"); return null; } if (!File.Exists(metadata.ExecuteFilePath)) { - Log.Error($"|PluginConfig.GetPluginMetadata|execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); + API.LogError(ClassName, $"Execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); return null; } return metadata; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 09711051e..9b525f331 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1,20 +1,19 @@ -using Flow.Launcher.Core.ExternalPlugins; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using ISavable = Flow.Launcher.Plugin.ISavable; using Flow.Launcher.Plugin.SharedCommands; -using System.Text.Json; -using Flow.Launcher.Core.Resource; -using CommunityToolkit.Mvvm.DependencyInjection; +using IRemovable = Flow.Launcher.Core.Storage.IRemovable; +using ISavable = Flow.Launcher.Plugin.ISavable; namespace Flow.Launcher.Core.Plugin { @@ -23,7 +22,10 @@ namespace Flow.Launcher.Core.Plugin /// public static class PluginManager { + private static readonly string ClassName = nameof(PluginManager); + private static IEnumerable _contextMenuPlugins; + private static IEnumerable _homePlugins; public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); @@ -35,7 +37,7 @@ namespace Flow.Launcher.Core.Plugin private static PluginsSettings Settings; private static List _metadatas; - private static List _modifiedPlugins = new List(); + private static readonly List _modifiedPlugins = new(); /// /// Directories that will hold Flow Launcher plugin directory @@ -59,18 +61,34 @@ namespace Flow.Launcher.Core.Plugin /// public static void Save() { - foreach (var plugin in AllPlugins) + foreach (var pluginPair in AllPlugins) { - var savable = plugin.Plugin as ISavable; - savable?.Save(); + var savable = pluginPair.Plugin as ISavable; + try + { + savable?.Save(); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e); + } } API.SavePluginSettings(); + API.SavePluginCaches(); } public static async ValueTask DisposePluginsAsync() { foreach (var pluginPair in AllPlugins) + { + await DisposePluginAsync(pluginPair); + } + } + + private static async Task DisposePluginAsync(PluginPair pluginPair) + { + try { switch (pluginPair.Plugin) { @@ -82,6 +100,10 @@ namespace Flow.Launcher.Core.Plugin break; } } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e); + } } public static async Task ReloadDataAsync() @@ -155,6 +177,35 @@ namespace Flow.Launcher.Core.Plugin Settings = settings; Settings.UpdatePluginSettings(_metadatas); AllPlugins = PluginsLoader.Plugins(_metadatas, Settings); + // Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins + UpdatePluginDirectory(_metadatas); + } + + private static void UpdatePluginDirectory(List metadatas) + { + foreach (var metadata in metadatas) + { + if (AllowedLanguage.IsDotNet(metadata.Language)) + { + if (string.IsNullOrEmpty(metadata.AssemblyName)) + { + API.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}"); + continue; // Skip if AssemblyName is not set, which can happen for erroneous plugins + } + metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.AssemblyName); + metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.AssemblyName); + } + else + { + if (string.IsNullOrEmpty(metadata.Name)) + { + API.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}"); + continue; // Skip if Name is not set, which can happen for erroneous plugins + } + metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.Name); + metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.Name); + } + } } /// @@ -169,24 +220,37 @@ namespace Flow.Launcher.Core.Plugin { try { - var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.InitializePlugins|Init method time cost for <{pair.Metadata.Name}>", + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); pair.Metadata.InitTime += milliseconds; - Log.Info( - $"|PluginManager.InitializePlugins|Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); + API.LogInfo(ClassName, + $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); } catch (Exception e) { - Log.Exception(nameof(PluginManager), $"Fail to Init plugin: {pair.Metadata.Name}", e); - pair.Metadata.Disabled = true; - failedPlugins.Enqueue(pair); + API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); + if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled) + { + // If this plugin is already disabled, do not show error message again + // Or else it will be shown every time + API.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error"); + } + else + { + pair.Metadata.Disabled = true; + pair.Metadata.HomeDisabled = true; + failedPlugins.Enqueue(pair); + API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); + } } })); await Task.WhenAll(InitTasks); _contextMenuPlugins = GetPluginsForInterface(); + _homePlugins = GetPluginsForInterface(); + foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin @@ -205,9 +269,6 @@ namespace Flow.Launcher.Core.Plugin } } - InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface()); - InternationalizationManager.Instance.ChangeLanguage(Ioc.Default.GetRequiredService().Language); - if (failedPlugins.Any()) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); @@ -228,17 +289,20 @@ namespace Flow.Launcher.Core.Plugin if (query is null) return Array.Empty(); - if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword)) + if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) return GlobalPlugins; - - var plugin = NonGlobalPlugins[query.ActionKeyword]; return new List { plugin }; } + public static ICollection ValidPluginsForHomeQuery() + { + return _homePlugins.ToList(); + } + public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); @@ -246,7 +310,7 @@ namespace Flow.Launcher.Core.Plugin try { - var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.QueryForPlugin|Cost for {metadata.Name}", + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); @@ -270,7 +334,7 @@ namespace Flow.Launcher.Core.Plugin { Title = $"{metadata.Name}: Failed to respond!", SubTitle = "Select this result for more info", - IcoPath = Flow.Launcher.Infrastructure.Constant.ErrorIcon, + IcoPath = Constant.ErrorIcon, PluginDirectory = metadata.PluginDirectory, ActionKeywordAssigned = query.ActionKeyword, PluginID = metadata.ID, @@ -283,6 +347,36 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static async Task> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token) + { + var results = new List(); + var metadata = pair.Metadata; + + try + { + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false)); + + token.ThrowIfCancellationRequested(); + if (results == null) + return null; + UpdatePluginMetadata(results, metadata, query); + + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + // null will be fine since the results will only be added into queue if the token hasn't been cancelled + return null; + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e); + return null; + } + return results; + } + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) @@ -334,8 +428,8 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception( - $"|PluginManager.GetContextMenusForPlugin|Can't load context menus for plugin <{pluginPair.Metadata.Name}>", + API.LogException(ClassName, + $"Can't load context menus for plugin <{pluginPair.Metadata.Name}>", e); } } @@ -343,12 +437,17 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static bool IsHomePlugin(string id) + { + return _homePlugins.Any(p => p.Metadata.ID == id); + } + public static bool ActionKeywordRegistered(string actionKeyword) { // this method is only checking for action keywords (defined as not '*') registration // hence the actionKeyword != Query.GlobalPluginWildcardSign logic - return actionKeyword != Query.GlobalPluginWildcardSign - && NonGlobalPlugins.ContainsKey(actionKeyword); + return actionKeyword != Query.GlobalPluginWildcardSign + && NonGlobalPlugins.ContainsKey(actionKeyword); } /// @@ -367,7 +466,16 @@ namespace Flow.Launcher.Core.Plugin NonGlobalPlugins[newActionKeyword] = plugin; } + // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Add(newActionKeyword); + if (plugin.Metadata.ActionKeywords.Count > 0) + { + plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; + } + else + { + plugin.Metadata.ActionKeyword = string.Empty; + } } /// @@ -388,16 +496,15 @@ namespace Flow.Launcher.Core.Plugin if (oldActionkeyword != Query.GlobalPluginWildcardSign) NonGlobalPlugins.Remove(oldActionkeyword); - + // Update action keywords and action keyword in plugin metadata plugin.Metadata.ActionKeywords.Remove(oldActionkeyword); - } - - public static void ReplaceActionKeyword(string id, string oldActionKeyword, string newActionKeyword) - { - if (oldActionKeyword != newActionKeyword) + if (plugin.Metadata.ActionKeywords.Count > 0) { - AddActionKeyword(id, newActionKeyword); - RemoveActionKeyword(id, oldActionKeyword); + plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0]; + } + else + { + plugin.Metadata.ActionKeyword = string.Empty; } } @@ -428,37 +535,26 @@ namespace Flow.Launcher.Core.Plugin #region Public functions - public static bool PluginModified(string uuid) + public static bool PluginModified(string id) { - return _modifiedPlugins.Contains(uuid); + return _modifiedPlugins.Contains(id); } - - /// - /// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url, - /// unless it's a local path installation - /// - public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) + public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) { InstallPlugin(newVersion, zipFilePath, checkModified:false); - UninstallPlugin(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false); + await UninstallPluginAsync(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false); _modifiedPlugins.Add(existingVersion.ID); } - /// - /// Install a plugin. By default will remove the zip file if installation is from url, unless it's a local path installation - /// public static void InstallPlugin(UserPlugin plugin, string zipFilePath) { InstallPlugin(plugin, zipFilePath, checkModified: true); } - /// - /// Uninstall a plugin. - /// - public static void UninstallPlugin(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false) + public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false) { - UninstallPlugin(plugin, removePluginFromSettings, removePluginSettings, true); + await UninstallPluginAsync(plugin, removePluginFromSettings, removePluginSettings, true); } #endregion @@ -499,20 +595,20 @@ namespace Flow.Launcher.Core.Plugin var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; var defaultPluginIDs = new List - { - "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark - "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator - "572be03c74c642baae319fc283e561a8", // Explorer - "6A122269676E40EB86EB543B945932B9", // PluginIndicator - "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager - "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller - "791FC278BA414111B8D1886DFE447410", // Program - "D409510CD0D2481F853690A07E6DC426", // Shell - "CEA08895D2544B019B2E9C5009600DF4", // Sys - "0308FD86DE0A4DEE8D62B9B535370992", // URL - "565B73353DBF4806919830B9202EE3BF", // WebSearch - "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings - }; + { + "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark + "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator + "572be03c74c642baae319fc283e561a8", // Explorer + "6A122269676E40EB86EB543B945932B9", // PluginIndicator + "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager + "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller + "791FC278BA414111B8D1886DFE447410", // Program + "D409510CD0D2481F853690A07E6DC426", // Shell + "CEA08895D2544B019B2E9C5009600DF4", // Sys + "0308FD86DE0A4DEE8D62B9B535370992", // URL + "565B73353DBF4806919830B9202EE3BF", // WebSearch + "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings + }; // Treat default plugin differently, it needs to be removable along with each flow release var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) @@ -530,7 +626,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - Log.Exception($"|PluginManager.InstallPlugin|Failed to delete temp folder {tempFolderPluginPath}", e); + API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); } if (checkModified) @@ -539,64 +635,63 @@ namespace Flow.Launcher.Core.Plugin } } - internal static void UninstallPlugin(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) + internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { throw new ArgumentException($"Plugin {plugin.Name} has been modified"); } + if (removePluginSettings || removePluginFromSettings) + { + // If we want to remove plugin from AllPlugins, + // we need to dispose them so that they can release file handles + // which can help FL to delete the plugin settings & cache folders successfully + var pluginPairs = AllPlugins.FindAll(p => p.Metadata.ID == plugin.ID); + foreach (var pluginPair in pluginPairs) + { + await DisposePluginAsync(pluginPair); + } + } + if (removePluginSettings) { - if (AllowedLanguage.IsDotNet(plugin.Language)) // for the plugin in .NET, we can use assembly loader + // For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances + if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable) { - var assemblyLoader = new PluginAssemblyLoader(plugin.ExecuteFilePath); - var assembly = assemblyLoader.LoadAssemblyAndDependencies(); - var assemblyName = assembly.GetName().Name; - - // if user want to remove the plugin settings, we cannot call save method for the plugin json storage instance of this plugin - // so we need to remove it from the api instance - var method = API.GetType().GetMethod("RemovePluginSettings"); - var pluginJsonStorage = method?.Invoke(API, new object[] { assemblyName }); - - // if there exists a json storage for current plugin, we need to delete the directory path - if (pluginJsonStorage != null) - { - var deleteMethod = pluginJsonStorage.GetType().GetMethod("DeleteDirectory"); - try - { - deleteMethod?.Invoke(pluginJsonStorage, null); - } - catch (Exception e) - { - Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e); - API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), - string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); - } - } + removable.RemovePluginSettings(plugin.AssemblyName); + removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath); } - else // the plugin with json prc interface + + try { - var pluginPair = AllPlugins.FirstOrDefault(p => p.Metadata.ID == plugin.ID); - if (pluginPair != null && pluginPair.Plugin is JsonRPCPlugin jsonRpcPlugin) - { - try - { - jsonRpcPlugin.DeletePluginSettingsDirectory(); - } - catch (Exception e) - { - Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e); - API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), - string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); - } - } + var pluginSettingsDirectory = plugin.PluginSettingsDirectoryPath; + if (Directory.Exists(pluginSettingsDirectory)) + Directory.Delete(pluginSettingsDirectory, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), + string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); } } if (removePluginFromSettings) { - Settings.Plugins.Remove(plugin.ID); + try + { + var pluginCacheDirectory = plugin.PluginCacheDirectoryPath; + if (Directory.Exists(pluginCacheDirectory)) + Directory.Delete(pluginCacheDirectory, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e); + API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"), + string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); + } + Settings.RemovePluginSettings(plugin.ID); AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); } diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 4827cf69d..256c36065 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -11,12 +11,17 @@ using Flow.Launcher.Infrastructure.Logger; #pragma warning restore IDE0005 using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher.Core.Plugin { public static class PluginsLoader { + private static readonly string ClassName = nameof(PluginsLoader); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + public static List Plugins(List metadatas, PluginsSettings settings) { var dotnetPlugins = DotNetPlugins(metadatas); @@ -50,7 +55,7 @@ namespace Flow.Launcher.Core.Plugin return plugins; } - public static IEnumerable DotNetPlugins(List source) + private static IEnumerable DotNetPlugins(List source) { var erroredPlugins = new List(); @@ -59,8 +64,7 @@ namespace Flow.Launcher.Core.Plugin foreach (var metadata in metadatas) { - var milliseconds = Stopwatch.Debug( - $"|PluginsLoader.DotNetPlugins|Constructor init cost for {metadata.Name}", () => + var milliseconds = API.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => { Assembly assembly = null; IAsyncPlugin plugin = null; @@ -74,28 +78,30 @@ namespace Flow.Launcher.Core.Plugin typeof(IAsyncPlugin)); plugin = Activator.CreateInstance(type) as IAsyncPlugin; + + metadata.AssemblyName = assembly.GetName().Name; } #if DEBUG - catch (Exception e) + catch (Exception) { throw; } #else catch (Exception e) when (assembly == null) { - Log.Exception($"|PluginsLoader.DotNetPlugins|Couldn't load assembly for the plugin: {metadata.Name}", e); + Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); } catch (InvalidOperationException e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); + Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); } catch (ReflectionTypeLoadException e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); + Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); } catch (Exception e) { - Log.Exception($"|PluginsLoader.DotNetPlugins|The following plugin has errored and can not be loaded: <{metadata.Name}>", e); + Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); } #endif @@ -112,7 +118,7 @@ namespace Flow.Launcher.Core.Plugin if (erroredPlugins.Count > 0) { - var errorPluginString = String.Join(Environment.NewLine, erroredPlugins); + var errorPluginString = string.Join(Environment.NewLine, erroredPlugins); var errorMessage = "The following " + (erroredPlugins.Count > 1 ? "plugins have " : "plugin has ") @@ -130,23 +136,31 @@ namespace Flow.Launcher.Core.Plugin return plugins; } - public static IEnumerable ExecutablePlugins(IEnumerable source) + private static IEnumerable ExecutablePlugins(IEnumerable source) { return source .Where(o => o.Language.Equals(AllowedLanguage.Executable, StringComparison.OrdinalIgnoreCase)) - .Select(metadata => new PluginPair + .Select(metadata => { - Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), Metadata = metadata + return new PluginPair + { + Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), + Metadata = metadata + }; }); } - public static IEnumerable ExecutableV2Plugins(IEnumerable source) + private static IEnumerable ExecutableV2Plugins(IEnumerable source) { return source .Where(o => o.Language.Equals(AllowedLanguage.ExecutableV2, StringComparison.OrdinalIgnoreCase)) - .Select(metadata => new PluginPair + .Select(metadata => { - Plugin = new ExecutablePluginV2(metadata.ExecuteFilePath), Metadata = metadata + return new PluginPair + { + Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), + Metadata = metadata + }; }); } } diff --git a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs index bae263157..7a6bf07e2 100644 --- a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs @@ -1,21 +1,19 @@ -#nullable enable - -using System; -using System.Collections.Generic; +using System; using System.Diagnostics; using System.IO.Pipelines; using System.Threading.Tasks; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using Meziantou.Framework.Win32; -using Microsoft.VisualBasic.ApplicationServices; using Nerdbank.Streams; +#nullable enable + namespace Flow.Launcher.Core.Plugin { internal abstract class ProcessStreamPluginV2 : JsonRPCPluginV2 { - private static JobObject _jobObject = new JobObject(); + private static readonly JobObject _jobObject = new(); static ProcessStreamPluginV2() { @@ -66,11 +64,10 @@ namespace Flow.Launcher.Core.Plugin ClientPipe = new DuplexPipe(reader, writer); } - public override async Task ReloadDataAsync() { var oldProcess = ClientProcess; - ClientProcess = Process.Start(StartInfo); + ClientProcess = Process.Start(StartInfo)!; ArgumentNullException.ThrowIfNull(ClientProcess); SetupPipe(ClientProcess); await base.ReloadDataAsync(); @@ -79,7 +76,6 @@ namespace Flow.Launcher.Core.Plugin oldProcess.Dispose(); } - public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3dc7877ac..25a32a728 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Flow.Launcher.Plugin; @@ -8,10 +8,24 @@ namespace Flow.Launcher.Core.Plugin { public static Query Build(string text, Dictionary nonGlobalPlugins) { + // home query + if (string.IsNullOrEmpty(text)) + { + return new Query() + { + Search = string.Empty, + RawQuery = string.Empty, + SearchTerms = Array.Empty(), + ActionKeyword = string.Empty, + IsHomeQuery = true + }; + } + // replace multiple white spaces with one white space var terms = text.Split(Query.TermSeparator, StringSplitOptions.RemoveEmptyEntries); if (terms.Length == 0) - { // nothing was typed + { + // nothing was typed return null; } @@ -21,25 +35,28 @@ namespace Flow.Launcher.Core.Plugin string[] searchTerms; if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled) - { // use non global plugin for query + { + // use non global plugin for query actionKeyword = possibleActionKeyword; search = terms.Length > 1 ? rawQuery[(actionKeyword.Length + 1)..].TrimStart() : string.Empty; searchTerms = terms[1..]; } else - { // non action keyword + { + // non action keyword actionKeyword = string.Empty; search = rawQuery.TrimStart(); searchTerms = terms; } - return new Query () + return new Query() { Search = search, RawQuery = rawQuery, SearchTerms = searchTerms, - ActionKeyword = actionKeyword + ActionKeyword = actionKeyword, + IsHomeQuery = false }; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/Internationalization.cs b/Flow.Launcher.Core/Resource/Internationalization.cs index e2a66656a..b32b09e8f 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Windows; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using System.Globalization; @@ -17,13 +16,19 @@ namespace Flow.Launcher.Core.Resource { public class Internationalization { + private static readonly string ClassName = nameof(Internationalization); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private const string Folder = "Languages"; private const string DefaultLanguageCode = "en"; private const string DefaultFile = "en.xaml"; private const string Extension = ".xaml"; private readonly Settings _settings; - private readonly List _languageDirectories = new List(); - private readonly List _oldResources = new List(); + private readonly List _languageDirectories = new(); + private readonly List _oldResources = new(); private readonly string SystemLanguageCode; public Internationalization(Settings settings) @@ -67,9 +72,9 @@ namespace Flow.Launcher.Core.Resource return DefaultLanguageCode; } - internal void AddPluginLanguageDirectories(IEnumerable plugins) + private void AddPluginLanguageDirectories() { - foreach (var plugin in plugins) + foreach (var plugin in PluginManager.GetPluginsForInterface()) { var location = Assembly.GetAssembly(plugin.Plugin.GetType()).Location; var dir = Path.GetDirectoryName(location); @@ -80,7 +85,7 @@ namespace Flow.Launcher.Core.Resource } else { - Log.Error($"|Internationalization.AddPluginLanguageDirectories|Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); + API.LogError(ClassName, $"Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); } } @@ -96,6 +101,32 @@ namespace Flow.Launcher.Core.Resource _oldResources.Clear(); } + /// + /// Initialize language. Will change app language and plugin language based on settings. + /// + public async Task InitializeLanguageAsync() + { + // Get actual language + var languageCode = _settings.Language; + if (languageCode == Constant.SystemLanguageCode) + { + languageCode = SystemLanguageCode; + } + + // Get language by language code and change language + var language = GetLanguageByLanguageCode(languageCode); + + // Add plugin language directories first so that we can load language files from plugins + AddPluginLanguageDirectories(); + + // Change language + await ChangeLanguageAsync(language); + } + + /// + /// Change language during runtime. Will change app language and plugin language & save settings. + /// + /// public void ChangeLanguage(string languageCode) { languageCode = languageCode.NonNull(); @@ -110,16 +141,21 @@ namespace Flow.Launcher.Core.Resource // Get language by language code and change language var language = GetLanguageByLanguageCode(languageCode); - ChangeLanguage(language, isSystem); + + // Change language + _ = ChangeLanguageAsync(language); + + // Save settings + _settings.Language = isSystem ? Constant.SystemLanguageCode : language.LanguageCode; } - private Language GetLanguageByLanguageCode(string languageCode) + private static Language GetLanguageByLanguageCode(string languageCode) { var lowercase = languageCode.ToLower(); var language = AvailableLanguages.GetAvailableLanguages().FirstOrDefault(o => o.LanguageCode.ToLower() == lowercase); if (language == null) { - Log.Error($"|Internationalization.GetLanguageByLanguageCode|Language code can't be found <{languageCode}>"); + API.LogError(ClassName, $"Language code can't be found <{languageCode}>"); return AvailableLanguages.English; } else @@ -128,26 +164,22 @@ namespace Flow.Launcher.Core.Resource } } - private void ChangeLanguage(Language language, bool isSystem) + private async Task ChangeLanguageAsync(Language language) { - language = language.NonNull(); - + // Remove old language files and load language RemoveOldLanguageFiles(); if (language != AvailableLanguages.English) { LoadLanguage(language); } + // Culture of main thread // Use CreateSpecificCulture to preserve possible user-override settings in Windows, if Flow's language culture is the same as Windows's CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture(language.LanguageCode); CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture; - // Raise event after culture is set - _settings.Language = isSystem ? Constant.SystemLanguageCode : language.LanguageCode; - _ = Task.Run(() => - { - UpdatePluginMetadataTranslations(); - }); + // Raise event for plugins after culture is set + await Task.Run(UpdatePluginMetadataTranslations); } public bool PromptShouldUsePinyin(string languageCodeToSet) @@ -212,7 +244,7 @@ namespace Flow.Launcher.Core.Resource return list; } - public string GetTranslation(string key) + public static string GetTranslation(string key) { var translation = Application.Current.TryFindResource(key); if (translation is string) @@ -221,7 +253,7 @@ namespace Flow.Launcher.Core.Resource } else { - Log.Error($"|Internationalization.GetTranslation|No Translation for key {key}"); + API.LogError(ClassName, $"No Translation for key {key}"); return $"No Translation for key {key}"; } } @@ -230,8 +262,7 @@ namespace Flow.Launcher.Core.Resource { foreach (var p in PluginManager.GetPluginsForInterface()) { - var pluginI18N = p.Plugin as IPluginI18n; - if (pluginI18N == null) return; + if (p.Plugin is not IPluginI18n pluginI18N) return; try { p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); @@ -240,31 +271,31 @@ namespace Flow.Launcher.Core.Resource } catch (Exception e) { - Log.Exception($"|Internationalization.UpdatePluginMetadataTranslations|Failed for <{p.Metadata.Name}>", e); + API.LogException(ClassName, $"Failed for <{p.Metadata.Name}>", e); } } } - public string LanguageFile(string folder, string language) + private static string LanguageFile(string folder, string language) { if (Directory.Exists(folder)) { - string path = Path.Combine(folder, language); + var path = Path.Combine(folder, language); if (File.Exists(path)) { return path; } else { - Log.Error($"|Internationalization.LanguageFile|Language path can't be found <{path}>"); - string english = Path.Combine(folder, DefaultFile); + API.LogError(ClassName, $"Language path can't be found <{path}>"); + var english = Path.Combine(folder, DefaultFile); if (File.Exists(english)) { return english; } else { - Log.Error($"|Internationalization.LanguageFile|Default English Language path can't be found <{path}>"); + API.LogError(ClassName, $"Default English Language path can't be found <{path}>"); return string.Empty; } } diff --git a/Flow.Launcher.Core/Resource/InternationalizationManager.cs b/Flow.Launcher.Core/Resource/InternationalizationManager.cs deleted file mode 100644 index 5d718466c..000000000 --- a/Flow.Launcher.Core/Resource/InternationalizationManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using CommunityToolkit.Mvvm.DependencyInjection; - -namespace Flow.Launcher.Core.Resource -{ - [Obsolete("InternationalizationManager.Instance is obsolete. Use Ioc.Default.GetRequiredService() instead.")] - public static class InternationalizationManager - { - public static Internationalization Instance - => Ioc.Default.GetRequiredService(); - } -} diff --git a/Flow.Launcher.Core/Resource/LocalizationConverter.cs b/Flow.Launcher.Core/Resource/LocalizationConverter.cs index 81600e023..fdda33926 100644 --- a/Flow.Launcher.Core/Resource/LocalizationConverter.cs +++ b/Flow.Launcher.Core/Resource/LocalizationConverter.cs @@ -6,6 +6,7 @@ using System.Windows.Data; namespace Flow.Launcher.Core.Resource { + [Obsolete("LocalizationConverter is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] public class LocalizationConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs index 52a232334..3e1a19a76 100644 --- a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs +++ b/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs @@ -1,15 +1,19 @@ using System.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Resource { public class LocalizedDescriptionAttribute : DescriptionAttribute { - private readonly Internationalization _translator; + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + private readonly string _resourceKey; public LocalizedDescriptionAttribute(string resourceKey) { - _translator = InternationalizationManager.Instance; _resourceKey = resourceKey; } @@ -17,7 +21,7 @@ namespace Flow.Launcher.Core.Resource { get { - string description = _translator.GetTranslation(_resourceKey); + string description = API.GetTranslation(_resourceKey); return string.IsNullOrWhiteSpace(description) ? string.Format("[[{0}]]", _resourceKey) : description; } diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index cda125a39..a6e8dc6bf 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -3,21 +3,31 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Xml; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Markup; using System.Windows.Media; using System.Windows.Media.Effects; using System.Windows.Shell; +using System.Windows.Threading; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedModels; +using Microsoft.Win32; namespace Flow.Launcher.Core.Resource { public class Theme { + #region Properties & Fields + + private readonly string ClassName = nameof(Theme); + + public bool BlurEnabled { get; private set; } + private const string ThemeMetadataNamePrefix = "Name:"; private const string ThemeMetadataIsDarkPrefix = "IsDark:"; private const string ThemeMetadataHasBlurPrefix = "HasBlur:"; @@ -31,14 +41,14 @@ namespace Flow.Launcher.Core.Resource private string _oldTheme; private const string Folder = Constant.Themes; private const string Extension = ".xaml"; - private string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder); - private string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder); + private static string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder); + private static string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder); - public string CurrentTheme => _settings.Theme; + private Thickness _themeResizeBorderThickness; - public bool BlurEnabled { get; set; } + #endregion - private double mainWindowWidth; + #region Constructor public Theme(IPublicAPI publicAPI, Settings settings) { @@ -50,22 +60,29 @@ namespace Flow.Launcher.Core.Resource MakeSureThemeDirectoriesExist(); var dicts = Application.Current.Resources.MergedDictionaries; - _oldResource = dicts.First(d => + _oldResource = dicts.FirstOrDefault(d => { - if (d.Source == null) - return false; + if (d.Source == null) return false; var p = d.Source.AbsolutePath; - var dir = Path.GetDirectoryName(p).NonNull(); - var info = new DirectoryInfo(dir); - var f = info.Name; - var e = Path.GetExtension(p); - var found = f == Folder && e == Extension; - return found; + return p.Contains(Folder) && Path.GetExtension(p) == Extension; }); - _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + + if (_oldResource != null) + { + _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + } + else + { + _api.LogError(ClassName, "Current theme resource not found. Initializing with default theme."); + _oldTheme = Constant.DefaultTheme; + } } + #endregion + + #region Theme Resources + private void MakeSureThemeDirectoriesExist() { foreach (var dir in _themeDirectories.Where(dir => !Directory.Exists(dir))) @@ -76,73 +93,159 @@ namespace Flow.Launcher.Core.Resource } catch (Exception e) { - Log.Exception($"|Theme.MakesureThemeDirectoriesExist|Exception when create directory <{dir}>", e); + _api.LogException(ClassName, $"Exception when create directory <{dir}>", e); } } } - public bool ChangeTheme(string theme) - { - const string defaultTheme = Constant.DefaultTheme; - - string path = GetThemePath(theme); - try - { - if (string.IsNullOrEmpty(path)) - throw new DirectoryNotFoundException("Theme path can't be found <{path}>"); - - // reload all resources even if the theme itself hasn't changed in order to pickup changes - // to things like fonts - UpdateResourceDictionary(GetResourceDictionary(theme)); - - _settings.Theme = theme; - - - //always allow re-loading default theme, in case of failure of switching to a new theme from default theme - if (_oldTheme != theme || theme == defaultTheme) - { - _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); - } - - BlurEnabled = Win32Helper.IsBlurTheme(); - - if (_settings.UseDropShadowEffect && !BlurEnabled) - AddDropShadowEffectToCurrentTheme(); - - Win32Helper.SetBlurForWindow(Application.Current.MainWindow, BlurEnabled); - } - catch (DirectoryNotFoundException) - { - Log.Error($"|Theme.ChangeTheme|Theme <{theme}> path can't be found"); - if (theme != defaultTheme) - { - _api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_path_not_exists"), theme)); - ChangeTheme(defaultTheme); - } - return false; - } - catch (XamlParseException) - { - Log.Error($"|Theme.ChangeTheme|Theme <{theme}> fail to parse"); - if (theme != defaultTheme) - { - _api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_parse_error"), theme)); - ChangeTheme(defaultTheme); - } - return false; - } - return true; - } - private void UpdateResourceDictionary(ResourceDictionary dictionaryToUpdate) { - var dicts = Application.Current.Resources.MergedDictionaries; + // Add new resources + if (!Application.Current.Resources.MergedDictionaries.Contains(dictionaryToUpdate)) + { + Application.Current.Resources.MergedDictionaries.Add(dictionaryToUpdate); + } + + // Remove old resources + if (_oldResource != null && _oldResource != dictionaryToUpdate && + Application.Current.Resources.MergedDictionaries.Contains(_oldResource)) + { + Application.Current.Resources.MergedDictionaries.Remove(_oldResource); + } - dicts.Remove(_oldResource); - dicts.Add(dictionaryToUpdate); _oldResource = dictionaryToUpdate; } + /// + /// Updates only the font settings and refreshes the UI. + /// + public void UpdateFonts() + { + try + { + // Load a ResourceDictionary for the specified theme. + var themeName = _settings.Theme; + var dict = GetThemeResourceDictionary(themeName); + + // Apply font settings to the theme resource. + ApplyFontSettings(dict); + UpdateResourceDictionary(dict); + + // Must apply blur and drop shadow effects + _ = RefreshFrameAsync(); + } + catch (Exception e) + { + _api.LogException(ClassName, "Error occurred while updating theme fonts", e); + } + } + + /// + /// Loads and applies font settings to the theme resource. + /// + private void ApplyFontSettings(ResourceDictionary dict) + { + if (dict["QueryBoxStyle"] is Style queryBoxStyle && + dict["QuerySuggestionBoxStyle"] is Style querySuggestionBoxStyle) + { + var fontFamily = new FontFamily(_settings.QueryBoxFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); + + SetFontProperties(queryBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, true); + SetFontProperties(querySuggestionBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + + if (dict["ItemTitleStyle"] is Style resultItemStyle && + dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle && + dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && + dict["ItemHotkeySelectedStyle"] is Style resultHotkeyItemSelectedStyle) + { + var fontFamily = new FontFamily(_settings.ResultFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultFontStretch); + + SetFontProperties(resultItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultHotkeyItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultHotkeyItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + + if (dict["ItemSubTitleStyle"] is Style resultSubItemStyle && + dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) + { + var fontFamily = new FontFamily(_settings.ResultSubFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultSubFontStyle); + var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultSubFontWeight); + var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultSubFontStretch); + + SetFontProperties(resultSubItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + SetFontProperties(resultSubItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); + } + } + + /// + /// Applies font properties to a Style. + /// + private static void SetFontProperties(Style style, FontFamily fontFamily, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, bool isTextBox) + { + // Remove existing font-related setters + if (isTextBox) + { + // First, find the setters to remove and store them in a list + var settersToRemove = style.Setters + .OfType() + .Where(setter => + setter.Property == Control.FontFamilyProperty || + setter.Property == Control.FontStyleProperty || + setter.Property == Control.FontWeightProperty || + setter.Property == Control.FontStretchProperty) + .ToList(); + + // Remove each found setter one by one + foreach (var setter in settersToRemove) + { + style.Setters.Remove(setter); + } + + // Add New font setter + style.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + style.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + style.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + style.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); + + // Set caret brush (retain existing logic) + var caretBrushPropertyValue = style.Setters.OfType().Any(x => x.Property.Name == "CaretBrush"); + var foregroundPropertyValue = style.Setters.OfType().Where(x => x.Property.Name == "Foreground") + .Select(x => x.Value).FirstOrDefault(); + if (!caretBrushPropertyValue && foregroundPropertyValue != null) + style.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue)); + } + else + { + var settersToRemove = style.Setters + .OfType() + .Where(setter => + setter.Property == TextBlock.FontFamilyProperty || + setter.Property == TextBlock.FontStyleProperty || + setter.Property == TextBlock.FontWeightProperty || + setter.Property == TextBlock.FontStretchProperty) + .ToList(); + + foreach (var setter in settersToRemove) + { + style.Setters.Remove(setter); + } + + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, fontFamily)); + style.Setters.Add(new Setter(TextBlock.FontStyleProperty, fontStyle)); + style.Setters.Add(new Setter(TextBlock.FontWeightProperty, fontWeight)); + style.Setters.Add(new Setter(TextBlock.FontStretchProperty, fontStretch)); + } + } + private ResourceDictionary GetThemeResourceDictionary(string theme) { var uri = GetThemePath(theme); @@ -154,9 +257,7 @@ namespace Flow.Launcher.Core.Resource return dict; } - private ResourceDictionary CurrentThemeResourceDictionary() => GetThemeResourceDictionary(_settings.Theme); - - public ResourceDictionary GetResourceDictionary(string theme) + private ResourceDictionary GetResourceDictionary(string theme) { var dict = GetThemeResourceDictionary(theme); @@ -168,22 +269,22 @@ namespace Flow.Launcher.Core.Resource var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight)); - queryBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch)); + queryBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + queryBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + queryBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + queryBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); var caretBrushPropertyValue = queryBoxStyle.Setters.OfType().Any(x => x.Property.Name == "CaretBrush"); var foregroundPropertyValue = queryBoxStyle.Setters.OfType().Where(x => x.Property.Name == "Foreground") .Select(x => x.Value).FirstOrDefault(); if (!caretBrushPropertyValue && foregroundPropertyValue != null) //otherwise BaseQueryBoxStyle will handle styling - queryBoxStyle.Setters.Add(new Setter(TextBox.CaretBrushProperty, foregroundPropertyValue)); + queryBoxStyle.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue)); // Query suggestion box's font style is aligned with query box - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight)); - querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight)); + querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch)); } if (dict["ItemTitleStyle"] is Style resultItemStyle && @@ -213,36 +314,20 @@ namespace Flow.Launcher.Core.Resource Setter[] setters = { fontFamily, fontStyle, fontWeight, fontStretch }; Array.ForEach( - new[] { resultSubItemStyle,resultSubItemSelectedStyle}, o + new[] { resultSubItemStyle, resultSubItemSelectedStyle }, o => Array.ForEach(setters, p => o.Setters.Add(p))); } /* Ignore Theme Window Width and use setting */ var windowStyle = dict["WindowStyle"] as Style; var width = _settings.WindowSize; - windowStyle.Setters.Add(new Setter(Window.WidthProperty, width)); - mainWindowWidth = (double)width; + windowStyle.Setters.Add(new Setter(FrameworkElement.WidthProperty, width)); return dict; } - private ResourceDictionary GetCurrentResourceDictionary( ) + public ResourceDictionary GetCurrentResourceDictionary() { - return GetResourceDictionary(_settings.Theme); - } - - public List LoadAvailableThemes() - { - List themes = new List(); - foreach (var themeDirectory in _themeDirectories) - { - var filePaths = Directory - .GetFiles(themeDirectory) - .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) - .Select(GetThemeDataFromPath); - themes.AddRange(filePaths); - } - - return themes.OrderBy(o => o.Name).ToList(); + return GetResourceDictionary(_settings.Theme); } private ThemeData GetThemeDataFromPath(string path) @@ -264,15 +349,15 @@ namespace Flow.Launcher.Core.Resource { if (line.StartsWith(ThemeMetadataNamePrefix, StringComparison.OrdinalIgnoreCase)) { - name = line.Remove(0, ThemeMetadataNamePrefix.Length).Trim(); + name = line[ThemeMetadataNamePrefix.Length..].Trim(); } else if (line.StartsWith(ThemeMetadataIsDarkPrefix, StringComparison.OrdinalIgnoreCase)) { - isDark = bool.Parse(line.Remove(0, ThemeMetadataIsDarkPrefix.Length).Trim()); + isDark = bool.Parse(line[ThemeMetadataIsDarkPrefix.Length..].Trim()); } else if (line.StartsWith(ThemeMetadataHasBlurPrefix, StringComparison.OrdinalIgnoreCase)) { - hasBlur = bool.Parse(line.Remove(0, ThemeMetadataHasBlurPrefix.Length).Trim()); + hasBlur = bool.Parse(line[ThemeMetadataHasBlurPrefix.Length..].Trim()); } } @@ -293,6 +378,93 @@ namespace Flow.Launcher.Core.Resource return string.Empty; } + #endregion + + #region Get & Change Theme + + public ThemeData GetCurrentTheme() + { + var themes = GetAvailableThemes(); + var matchingTheme = themes.FirstOrDefault(t => t.FileNameWithoutExtension == _settings.Theme); + if (matchingTheme == null) + { + _api.LogWarn(ClassName, $"No matching theme found for '{_settings.Theme}'. Falling back to the first available theme."); + } + return matchingTheme ?? themes.FirstOrDefault(); + } + + public List GetAvailableThemes() + { + List themes = new List(); + foreach (var themeDirectory in _themeDirectories) + { + var filePaths = Directory + .GetFiles(themeDirectory) + .Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml")) + .Select(GetThemeDataFromPath); + themes.AddRange(filePaths); + } + + return themes.OrderBy(o => o.Name).ToList(); + } + + public bool ChangeTheme(string theme = null) + { + if (string.IsNullOrEmpty(theme)) + theme = _settings.Theme; + + string path = GetThemePath(theme); + try + { + if (string.IsNullOrEmpty(path)) + throw new DirectoryNotFoundException($"Theme path can't be found <{path}>"); + + // Retrieve theme resource – always use the resource with font settings applied. + var resourceDict = GetResourceDictionary(theme); + + UpdateResourceDictionary(resourceDict); + + _settings.Theme = theme; + + //always allow re-loading default theme, in case of failure of switching to a new theme from default theme + if (_oldTheme != theme || theme == Constant.DefaultTheme) + { + _oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath); + } + + BlurEnabled = IsBlurTheme(); + + // Apply blur and drop shadow effect so that we do not need to call it again + _ = RefreshFrameAsync(); + + return true; + } + catch (DirectoryNotFoundException) + { + _api.LogError(ClassName, $"Theme <{theme}> path can't be found"); + if (theme != Constant.DefaultTheme) + { + _api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_path_not_exists"), theme)); + ChangeTheme(Constant.DefaultTheme); + } + return false; + } + catch (XamlParseException) + { + _api.LogError(ClassName, $"Theme <{theme}> fail to parse"); + if (theme != Constant.DefaultTheme) + { + _api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_parse_error"), theme)); + ChangeTheme(Constant.DefaultTheme); + } + return false; + } + } + + #endregion + + #region Shadow Effect + public void AddDropShadowEffectToCurrentTheme() { var dict = GetCurrentResourceDictionary(); @@ -301,7 +473,7 @@ namespace Flow.Launcher.Core.Resource var effectSetter = new Setter { - Property = Border.EffectProperty, + Property = UIElement.EffectProperty, Value = new DropShadowEffect { Opacity = 0.3, @@ -311,13 +483,12 @@ namespace Flow.Launcher.Core.Resource } }; - var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; - if (marginSetter == null) + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is not Setter marginSetter) { var margin = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin); marginSetter = new Setter() { - Property = Border.MarginProperty, + Property = FrameworkElement.MarginProperty, Value = margin, }; windowBorderStyle.Setters.Add(marginSetter); @@ -347,14 +518,12 @@ namespace Flow.Launcher.Core.Resource var dict = GetCurrentResourceDictionary(); var windowBorderStyle = dict["WindowBorderStyle"] as Style; - var effectSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.EffectProperty) as Setter; - var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter; - - if (effectSetter != null) + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == UIElement.EffectProperty) is Setter effectSetter) { windowBorderStyle.Setters.Remove(effectSetter); } - if (marginSetter != null) + + if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is Setter marginSetter) { var currentMargin = (Thickness)marginSetter.Value; var newMargin = new Thickness( @@ -370,31 +539,393 @@ namespace Flow.Launcher.Core.Resource UpdateResourceDictionary(dict); } + public void SetResizeBorderThickness(WindowChrome windowChrome, bool fixedWindowSize) + { + if (fixedWindowSize) + { + windowChrome.ResizeBorderThickness = new Thickness(0); + } + else + { + windowChrome.ResizeBorderThickness = _themeResizeBorderThickness; + } + } + // because adding drop shadow effect will change the margin of the window, // we need to update the window chrome thickness to correct set the resize border - private static void SetResizeBoarderThickness(Thickness? effectMargin) + private void SetResizeBoarderThickness(Thickness? effectMargin) { var window = Application.Current.MainWindow; if (WindowChrome.GetWindowChrome(window) is WindowChrome windowChrome) { - Thickness thickness; + // Save the theme resize border thickness so that we can restore it if we change ResizeWindow setting if (effectMargin == null) { - thickness = SystemParameters.WindowResizeBorderThickness; + _themeResizeBorderThickness = SystemParameters.WindowResizeBorderThickness; } else { - thickness = new Thickness( + _themeResizeBorderThickness = new Thickness( effectMargin.Value.Left + SystemParameters.WindowResizeBorderThickness.Left, effectMargin.Value.Top + SystemParameters.WindowResizeBorderThickness.Top, effectMargin.Value.Right + SystemParameters.WindowResizeBorderThickness.Right, effectMargin.Value.Bottom + SystemParameters.WindowResizeBorderThickness.Bottom); } - windowChrome.ResizeBorderThickness = thickness; + // Apply the resize border thickness to the window chrome + SetResizeBorderThickness(windowChrome, _settings.KeepMaxResults); } } - public record ThemeData(string FileNameWithoutExtension, string Name, bool? IsDark = null, bool? HasBlur = null); + #endregion + + #region Blur Handling + + /// + /// Refreshes the frame to apply the current theme settings. + /// + public async Task RefreshFrameAsync() + { + await Application.Current.Dispatcher.InvokeAsync(() => + { + // Get the actual backdrop type and drop shadow effect settings + var (backdropType, useDropShadowEffect) = GetActualValue(); + + // Remove OS minimizing/maximizing animation + // Methods.SetWindowAttribute(new WindowInteropHelper(mainWindow).Handle, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 3); + + // The timing of adding the shadow effect should vary depending on whether the theme is transparent. + if (BlurEnabled) + { + AutoDropShadow(useDropShadowEffect); + } + SetBlurForWindow(_settings.Theme, backdropType); + + if (!BlurEnabled) + { + AutoDropShadow(useDropShadowEffect); + } + }, DispatcherPriority.Render); + } + + /// + /// Sets the blur for a window via SetWindowCompositionAttribute + /// + public async Task SetBlurForWindowAsync() + { + await Application.Current.Dispatcher.InvokeAsync(() => + { + // Get the actual backdrop type and drop shadow effect settings + var (backdropType, _) = GetActualValue(); + + SetBlurForWindow(_settings.Theme, backdropType); + }, DispatcherPriority.Render); + } + + /// + /// Gets the actual backdrop type and drop shadow effect settings based on the current theme status. + /// + public (BackdropTypes BackdropType, bool UseDropShadowEffect) GetActualValue() + { + var backdropType = _settings.BackdropType; + var useDropShadowEffect = _settings.UseDropShadowEffect; + + // When changed non-blur theme, change to backdrop to none + if (!BlurEnabled) + { + backdropType = BackdropTypes.None; + } + + // Dropshadow on and control disabled.(user can't change dropshadow with blur theme) + if (BlurEnabled) + { + useDropShadowEffect = true; + } + + return (backdropType, useDropShadowEffect); + } + + private void SetBlurForWindow(string theme, BackdropTypes backdropType) + { + var dict = GetResourceDictionary(theme); + if (dict == null) return; + + var windowBorderStyle = dict.Contains("WindowBorderStyle") ? dict["WindowBorderStyle"] as Style : null; + if (windowBorderStyle == null) return; + + var mainWindow = Application.Current.MainWindow; + if (mainWindow == null) return; + + // Check if the theme supports blur + bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b; + if (BlurEnabled && hasBlur && Win32Helper.IsBackdropSupported()) + { + // If the BackdropType is Mica or MicaAlt, set the windowborderstyle's background to transparent + if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt) + { + windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType().FirstOrDefault(x => x.Property.Name == "Background")); + windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)))); + } + else if (backdropType == BackdropTypes.Acrylic) + { + windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType().FirstOrDefault(x => x.Property.Name == "Background")); + windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Colors.Transparent))); + } + + // For themes with blur enabled, the window border is rendered by the system, so it's treated as a simple rectangle regardless of thickness. + //(This is to avoid issues when the window is forcibly changed to a rectangular shape during snap scenarios.) + var cornerRadiusSetter = windowBorderStyle.Setters.OfType().FirstOrDefault(x => x.Property == Border.CornerRadiusProperty); + if (cornerRadiusSetter != null) + cornerRadiusSetter.Value = new CornerRadius(0); + else + windowBorderStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(0))); + + // Apply the blur effect + Win32Helper.DWMSetBackdropForWindow(mainWindow, backdropType); + ColorizeWindow(theme, backdropType); + } + else + { + // Apply default style when Blur is disabled + Win32Helper.DWMSetBackdropForWindow(mainWindow, BackdropTypes.None); + ColorizeWindow(theme, backdropType); + } + + UpdateResourceDictionary(dict); + } + + private void AutoDropShadow(bool useDropShadowEffect) + { + SetWindowCornerPreference("Default"); + RemoveDropShadowEffectFromCurrentTheme(); + if (useDropShadowEffect) + { + if (BlurEnabled && Win32Helper.IsBackdropSupported()) + { + SetWindowCornerPreference("Round"); + } + else + { + SetWindowCornerPreference("Default"); + AddDropShadowEffectToCurrentTheme(); + } + } + else + { + if (BlurEnabled && Win32Helper.IsBackdropSupported()) + { + SetWindowCornerPreference("Default"); + } + else + { + RemoveDropShadowEffectFromCurrentTheme(); + } + } + } + + private static void SetWindowCornerPreference(string cornerType) + { + Window mainWindow = Application.Current.MainWindow; + if (mainWindow == null) + return; + + Win32Helper.DWMSetCornerPreferenceForWindow(mainWindow, cornerType); + } + + // Get Background Color from WindowBorderStyle when there not color for BG. + // for theme has not "LightBG" or "DarkBG" case. + private Color GetWindowBorderStyleBackground(string theme) + { + var Resources = GetThemeResourceDictionary(theme); + var windowBorderStyle = (Style)Resources["WindowBorderStyle"]; + + var backgroundSetter = windowBorderStyle.Setters + .OfType() + .FirstOrDefault(s => s.Property == Border.BackgroundProperty); + + if (backgroundSetter != null) + { + // Background's Value is DynamicColor Case + var backgroundValue = backgroundSetter.Value; + + if (backgroundValue is SolidColorBrush solidColorBrush) + { + return solidColorBrush.Color; // Return SolidColorBrush's Color + } + else if (backgroundValue is DynamicResourceExtension dynamicResource) + { + // When DynamicResource Extension it is, Key is resource's name. + var resourceKey = backgroundSetter.Value.ToString(); + + // find key in resource and return color. + if (Resources.Contains(resourceKey)) + { + var colorResource = Resources[resourceKey]; + if (colorResource is SolidColorBrush colorBrush) + { + return colorBrush.Color; + } + else if (colorResource is Color color) + { + return color; + } + } + } + } + + return Colors.Transparent; // Default is transparent + } + + private void ApplyPreviewBackground(Color? bgColor = null) + { + if (bgColor == null) return; + + // Create a new Style for the preview + var previewStyle = new Style(typeof(Border)); + + // Get the original WindowBorderStyle + if (Application.Current.Resources.Contains("WindowBorderStyle") && + Application.Current.Resources["WindowBorderStyle"] is Style originalStyle) + { + // Copy the original style, including the base style if it exists + CopyStyle(originalStyle, previewStyle); + } + + // Apply background color (remove transparency in color) + Color backgroundColor = Color.FromRgb(bgColor.Value.R, bgColor.Value.G, bgColor.Value.B); + previewStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(backgroundColor))); + + // The blur theme keeps the corner round fixed (applying DWM code to modify it causes rendering issues). + // The non-blur theme retains the previously set WindowBorderStyle. + if (BlurEnabled) + { + previewStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(5))); + previewStyle.Setters.Add(new Setter(Border.BorderThicknessProperty, new Thickness(1))); + } + + // Set the new style to the resource + Application.Current.Resources["PreviewWindowBorderStyle"] = previewStyle; + } + + private void CopyStyle(Style originalStyle, Style targetStyle) + { + // If the style is based on another style, copy the base style first + if (originalStyle.BasedOn != null) + { + CopyStyle(originalStyle.BasedOn, targetStyle); + } + + // Copy the setters from the original style + foreach (var setter in originalStyle.Setters.OfType()) + { + targetStyle.Setters.Add(new Setter(setter.Property, setter.Value)); + } + } + + private void ColorizeWindow(string theme, BackdropTypes backdropType) + { + var dict = GetThemeResourceDictionary(theme); + if (dict == null) return; + + var mainWindow = Application.Current.MainWindow; + if (mainWindow == null) return; + + // Check if the theme supports blur + bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b; + + // SystemBG value check (Auto, Light, Dark) + string systemBG = dict.Contains("SystemBG") ? dict["SystemBG"] as string : "Auto"; // 기본값 Auto + + // Check the user's ColorScheme setting + string colorScheme = _settings.ColorScheme; + + // Check system dark mode setting (read AppsUseLightTheme value) + int themeValue = (int)Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme", 1); + bool isSystemDark = themeValue == 0; + + // Final decision on whether to use dark mode + bool useDarkMode = false; + + // If systemBG is not "Auto", prioritize it over ColorScheme and set the mode based on systemBG value + if (systemBG == "Dark") + { + useDarkMode = true; // Dark + } + else if (systemBG == "Light") + { + useDarkMode = false; // Light + } + else if (systemBG == "Auto") + { + // If systemBG is "Auto", decide based on ColorScheme + if (colorScheme == "Dark") + useDarkMode = true; + else if (colorScheme == "Light") + useDarkMode = false; + else + useDarkMode = isSystemDark; // Auto (based on system setting) + } + + // Apply DWM Dark Mode + Win32Helper.DWMSetDarkModeForWindow(mainWindow, useDarkMode); + + Color LightBG; + Color DarkBG; + + // Retrieve LightBG value (fallback to WindowBorderStyle background color if not found) + try + { + LightBG = dict.Contains("LightBG") ? (Color)dict["LightBG"] : GetWindowBorderStyleBackground(theme); + } + catch (Exception) + { + LightBG = GetWindowBorderStyleBackground(theme); + } + + // Retrieve DarkBG value (fallback to LightBG if not found) + try + { + DarkBG = dict.Contains("DarkBG") ? (Color)dict["DarkBG"] : LightBG; + } + catch (Exception) + { + DarkBG = LightBG; + } + + // Select background color based on ColorScheme and SystemBG + Color selectedBG = useDarkMode ? DarkBG : LightBG; + ApplyPreviewBackground(selectedBG); + + bool isBlurAvailable = hasBlur && Win32Helper.IsBackdropSupported(); // Windows 11 미만이면 hasBlur를 강제 false + + if (!isBlurAvailable) + { + mainWindow.Background = Brushes.Transparent; + } + else + { + // Only set the background to transparent if the theme supports blur + if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt) + { + mainWindow.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); + } + else + { + mainWindow.Background = new SolidColorBrush(selectedBG); + } + } + } + + private static bool IsBlurTheme() + { + if (!Win32Helper.IsBackdropSupported()) // Windows 11 미만이면 무조건 false + return false; + + var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); + + return resource is bool b && b; + } + + #endregion } } diff --git a/Flow.Launcher.Core/Resource/ThemeManager.cs b/Flow.Launcher.Core/Resource/ThemeManager.cs deleted file mode 100644 index 3cbe8319a..000000000 --- a/Flow.Launcher.Core/Resource/ThemeManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using CommunityToolkit.Mvvm.DependencyInjection; - -namespace Flow.Launcher.Core.Resource -{ - [Obsolete("ThemeManager.Instance is obsolete. Use Ioc.Default.GetRequiredService() instead.")] - public class ThemeManager - { - public static Theme Instance - => Ioc.Default.GetRequiredService(); - } -} diff --git a/Flow.Launcher.Core/Resource/TranslationConverter.cs b/Flow.Launcher.Core/Resource/TranslationConverter.cs index ebab99e5b..eb0032758 100644 --- a/Flow.Launcher.Core/Resource/TranslationConverter.cs +++ b/Flow.Launcher.Core/Resource/TranslationConverter.cs @@ -1,19 +1,25 @@ using System; using System.Globalization; using System.Windows.Data; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Core.Resource { public class TranslationConverter : IValueConverter { + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var key = value.ToString(); - if (String.IsNullOrEmpty(key)) - return key; - return InternationalizationManager.Instance.GetTranslation(key); + if (string.IsNullOrEmpty(key)) return key; + return API.GetTranslation(key); } - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException(); + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new InvalidOperationException(); } } diff --git a/Flow.Launcher.Core/Storage/IRemovable.cs b/Flow.Launcher.Core/Storage/IRemovable.cs new file mode 100644 index 000000000..bcf1cdd5e --- /dev/null +++ b/Flow.Launcher.Core/Storage/IRemovable.cs @@ -0,0 +1,19 @@ +namespace Flow.Launcher.Core.Storage; + +/// +/// Remove storage instances from instance +/// +public interface IRemovable +{ + /// + /// Remove all instances of one plugin + /// + /// + public void RemovePluginSettings(string assemblyName); + + /// + /// Remove all instances of one plugin + /// + /// + public void RemovePluginCaches(string cacheDirectory); +} diff --git a/Flow.Launcher.Core/Updater.cs b/Flow.Launcher.Core/Updater.cs index 9a77ece32..bc3655f69 100644 --- a/Flow.Launcher.Core/Updater.cs +++ b/Flow.Launcher.Core/Updater.cs @@ -4,19 +4,18 @@ using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using System.Windows; -using JetBrains.Annotations; -using Squirrel; -using Flow.Launcher.Core.Resource; using Flow.Launcher.Plugin.SharedCommands; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using System.Text.Json.Serialization; -using System.Threading; +using JetBrains.Annotations; +using Squirrel; namespace Flow.Launcher.Core { @@ -24,6 +23,8 @@ namespace Flow.Launcher.Core { public string GitHubRepository { get; init; } + private static readonly string ClassName = nameof(Updater); + private readonly IPublicAPI _api; public Updater(IPublicAPI publicAPI, string gitHubRepository) @@ -51,7 +52,7 @@ namespace Flow.Launcher.Core var newReleaseVersion = Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString()); var currentVersion = Version.Parse(Constant.Version); - Log.Info($"|Updater.UpdateApp|Future Release <{newUpdateInfo.FutureReleaseEntry.Formatted()}>"); + _api.LogInfo(ClassName, $"Future Release <{Formatted(newUpdateInfo.FutureReleaseEntry)}>"); if (newReleaseVersion <= currentVersion) { @@ -70,7 +71,7 @@ namespace Flow.Launcher.Core if (DataLocation.PortableDataLocationInUse()) { - var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion.ToString()}\\{DataLocation.PortableFolderName}"; + var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion}\\{DataLocation.PortableFolderName}"; FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s)); if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s))) _api.ShowMsgBox(string.Format(_api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"), @@ -84,7 +85,7 @@ namespace Flow.Launcher.Core var newVersionTips = NewVersionTips(newReleaseVersion.ToString()); - Log.Info($"|Updater.UpdateApp|Update success:{newVersionTips}"); + _api.LogInfo(ClassName, $"Update success:{newVersionTips}"); if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) { @@ -93,10 +94,14 @@ namespace Flow.Launcher.Core } catch (Exception e) { - if ((e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException)) - Log.Exception($"|Updater.UpdateApp|Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); + if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) + { + _api.LogException(ClassName, $"Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); + } else - Log.Exception($"|Updater.UpdateApp|Error Occurred", e); + { + _api.LogException(ClassName, $"Error Occurred", e); + } if (!silentUpdate) _api.ShowMsg(_api.GetTranslation("update_flowlauncher_fail"), @@ -122,14 +127,14 @@ namespace Flow.Launcher.Core } // https://github.com/Squirrel/Squirrel.Windows/blob/master/src/Squirrel/UpdateManager.Factory.cs - private async Task GitHubUpdateManagerAsync(string repository) + private static async Task GitHubUpdateManagerAsync(string repository) { var uri = new Uri(repository); var api = $"https://api.github.com/repos{uri.AbsolutePath}/releases"; await using var jsonStream = await Http.GetStreamAsync(api).ConfigureAwait(false); - var releases = await System.Text.Json.JsonSerializer.DeserializeAsync>(jsonStream).ConfigureAwait(false); + var releases = await JsonSerializer.DeserializeAsync>(jsonStream).ConfigureAwait(false); var latest = releases.Where(r => !r.Prerelease).OrderByDescending(r => r.PublishedAt).First(); var latestUrl = latest.HtmlUrl.Replace("/tag/", "/download/"); @@ -144,12 +149,21 @@ namespace Flow.Launcher.Core return manager; } - public string NewVersionTips(string version) + private string NewVersionTips(string version) { - var translator = InternationalizationManager.Instance; - var tips = string.Format(translator.GetTranslation("newVersionTips"), version); + var tips = string.Format(_api.GetTranslation("newVersionTips"), version); return tips; } + + private static string Formatted(T t) + { + var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions + { + WriteIndented = true + }); + + return formatted; + } } } diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index c86ed4324..13da9f79f 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -32,6 +32,7 @@ namespace Flow.Launcher.Infrastructure public static readonly string MissingImgIcon = Path.Combine(ImagesDirectory, "app_missing_img.png"); public static readonly string LoadingImgIcon = Path.Combine(ImagesDirectory, "loading.png"); public static readonly string ImageIcon = Path.Combine(ImagesDirectory, "image.png"); + public static readonly string HistoryIcon = Path.Combine(ImagesDirectory, "history.png"); public static string PythonPath; public static string NodePath; @@ -47,6 +48,7 @@ namespace Flow.Launcher.Infrastructure public const string Themes = "Themes"; public const string Settings = "Settings"; public const string Logs = "Logs"; + public const string Cache = "Cache"; public const string Website = "https://flowlauncher.com"; public const string SponsorPage = "https://github.com/sponsors/Flow-Launcher"; diff --git a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs index b97c096c3..1085cc833 100644 --- a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs +++ b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs @@ -67,8 +67,6 @@ namespace Flow.Launcher.Infrastructure return explorerWindows.Zip(zOrders).MinBy(x => x.Second).First; } - private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); - /// /// Gets the z-order for one or more windows atomically with respect to each other. In Windows, smaller z-order is higher. If the window is not top level, the z order is returned as -1. /// diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index b91da7114..31547200b 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -66,7 +66,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + + diff --git a/Flow.Launcher.Infrastructure/Helper.cs b/Flow.Launcher.Infrastructure/Helper.cs index 864d796c7..b02d84ca7 100644 --- a/Flow.Launcher.Infrastructure/Helper.cs +++ b/Flow.Launcher.Infrastructure/Helper.cs @@ -1,19 +1,11 @@ #nullable enable using System; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; namespace Flow.Launcher.Infrastructure { public static class Helper { - static Helper() - { - jsonFormattedSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - } - /// /// http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy /// @@ -36,55 +28,5 @@ namespace Flow.Launcher.Infrastructure throw new NullReferenceException(); } } - - public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory) - { - if (!Directory.Exists(dataDirectory)) - { - Directory.CreateDirectory(dataDirectory); - } - - foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory)) - { - var data = Path.GetFileName(bundledDataPath); - var dataPath = Path.Combine(dataDirectory, data.NonNull()); - if (!File.Exists(dataPath)) - { - File.Copy(bundledDataPath, dataPath); - } - else - { - var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc; - var time2 = new FileInfo(dataPath).LastWriteTimeUtc; - if (time1 != time2) - { - File.Copy(bundledDataPath, dataPath, true); - } - } - } - } - - public static void ValidateDirectory(string path) - { - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } - } - - private static readonly JsonSerializerOptions jsonFormattedSerializerOptions = new JsonSerializerOptions - { - WriteIndented = true - }; - - public static string Formatted(this T t) - { - var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions - { - WriteIndented = true - }); - - return formatted; - } } } diff --git a/Flow.Launcher.Infrastructure/Http/Http.cs b/Flow.Launcher.Infrastructure/Http/Http.cs index 030aff7cf..a29f8accf 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -1,30 +1,31 @@ -using System.IO; +using System; +using System.IO; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Threading; using Flow.Launcher.Plugin; -using CommunityToolkit.Mvvm.DependencyInjection; +using JetBrains.Annotations; namespace Flow.Launcher.Infrastructure.Http { public static class Http { + private static readonly string ClassName = nameof(Http); + private const string UserAgent = @"Mozilla/5.0 (Trident/7.0; rv:11.0) like Gecko"; - private static HttpClient client = new HttpClient(); + private static readonly HttpClient client = new(); static Http() { // need to be added so it would work on a win10 machine ServicePointManager.Expect100Continue = true; ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls - | SecurityProtocolType.Tls11 - | SecurityProtocolType.Tls12; + | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; client.DefaultRequestHeaders.Add("User-Agent", UserAgent); HttpClient.DefaultProxy = WebProxy; @@ -34,7 +35,7 @@ namespace Flow.Launcher.Infrastructure.Http public static HttpProxy Proxy { - private get { return proxy; } + private get => proxy; set { proxy = value; @@ -72,13 +73,13 @@ namespace Flow.Launcher.Infrastructure.Http ProxyProperty.Port => (new Uri($"http://{Proxy.Server}:{Proxy.Port}"), WebProxy.Credentials), ProxyProperty.UserName => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)), ProxyProperty.Password => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(null) }; } catch (UriFormatException e) { Ioc.Default.GetRequiredService().ShowMsg("Please try again", "Unable to parse Http Proxy"); - Log.Exception("Flow.Launcher.Infrastructure.Http", "Unable to parse Uri", e); + Log.Exception(ClassName, "Unable to parse Uri", e); } } @@ -134,7 +135,7 @@ namespace Flow.Launcher.Infrastructure.Http } catch (HttpRequestException e) { - Log.Exception("Infrastructure.Http", "Http Request Error", e, "DownloadAsync"); + Log.Exception(ClassName, "Http Request Error", e, "DownloadAsync"); throw; } } @@ -147,7 +148,7 @@ namespace Flow.Launcher.Infrastructure.Http /// The Http result as string. Null if cancellation requested public static Task GetAsync([NotNull] string url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return GetAsync(new Uri(url), token); } @@ -159,7 +160,7 @@ namespace Flow.Launcher.Infrastructure.Http /// The Http result as string. Null if cancellation requested public static async Task GetAsync([NotNull] Uri url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); using var response = await client.GetAsync(url, token); var content = await response.Content.ReadAsStringAsync(token); if (response.StatusCode != HttpStatusCode.OK) @@ -181,7 +182,6 @@ namespace Flow.Launcher.Infrastructure.Http public static Task GetStreamAsync([NotNull] string url, CancellationToken token = default) => GetStreamAsync(new Uri(url), token); - /// /// Send a GET request to the specified Uri with an HTTP completion option and a cancellation token as an asynchronous operation. /// @@ -191,7 +191,7 @@ namespace Flow.Launcher.Infrastructure.Http public static async Task GetStreamAsync([NotNull] Uri url, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return await client.GetStreamAsync(url, token); } @@ -202,7 +202,7 @@ namespace Flow.Launcher.Infrastructure.Http public static async Task GetResponseAsync([NotNull] Uri url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default) { - Log.Debug($"|Http.Get|Url <{url}>"); + Log.Debug(ClassName, $"Url <{url}>"); return await client.GetAsync(url, completionOption, token); } @@ -211,7 +211,14 @@ namespace Flow.Launcher.Infrastructure.Http /// public static async Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default) { - return await client.SendAsync(request, completionOption, token); + try + { + return await client.SendAsync(request, completionOption, token); + } + catch (System.Exception) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } } } } diff --git a/Flow.Launcher.Infrastructure/Image/ImageCache.cs b/Flow.Launcher.Infrastructure/Image/ImageCache.cs index ddbab4ef0..b8c12868b 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageCache.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageCache.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using System.Windows.Media; using BitFaster.Caching.Lfu; @@ -55,7 +53,6 @@ namespace Flow.Launcher.Infrastructure.Image return image != null; } - image = null; return false; } diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 6f7b1cd90..86df01a30 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -5,17 +5,19 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Windows.Documents; using System.Windows.Media; using System.Windows.Media.Imaging; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.Storage; -using static Flow.Launcher.Infrastructure.Http.Http; +using SharpVectors.Converters; +using SharpVectors.Renderers.Wpf; namespace Flow.Launcher.Infrastructure.Image { public static class ImageLoader { + private static readonly string ClassName = nameof(ImageLoader); + private static readonly ImageCache ImageCache = new(); private static SemaphoreSlim storageLock { get; } = new SemaphoreSlim(1, 1); private static BinaryStorage> _storage; @@ -27,9 +29,10 @@ namespace Flow.Launcher.Infrastructure.Image public static ImageSource LoadingImage { get; } = new BitmapImage(new Uri(Constant.LoadingImgIcon)); public const int SmallIconSize = 64; public const int FullIconSize = 256; - + public const int FullImageSize = 320; private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" }; + private static readonly string SvgExtension = ".svg"; public static async Task InitializeAsync() { @@ -37,6 +40,7 @@ namespace Flow.Launcher.Infrastructure.Image _hashGenerator = new ImageHashGenerator(); var usage = await LoadStorageToConcurrentDictionaryAsync(); + _storage.ClearData(); ImageCache.Initialize(usage); @@ -49,19 +53,18 @@ namespace Flow.Launcher.Infrastructure.Image _ = Task.Run(async () => { - await Stopwatch.NormalAsync("|ImageLoader.Initialize|Preload images cost", async () => + await Stopwatch.InfoAsync(ClassName, "Preload images cost", async () => { foreach (var (path, isFullImage) in usage) { await LoadAsync(path, isFullImage); } }); - Log.Info( - $"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); + Log.Info(ClassName, $"Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); }); } - public static async Task Save() + public static async Task SaveAsync() { await storageLock.WaitAsync(); @@ -71,12 +74,22 @@ namespace Flow.Launcher.Infrastructure.Image .Select(x => x.Key) .ToList()); } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to save image cache to file", e); + } finally { storageLock.Release(); } } + public static async Task WaitSaveAsync() + { + await storageLock.WaitAsync(); + storageLock.Release(); + } + private static async Task> LoadStorageToConcurrentDictionaryAsync() { await storageLock.WaitAsync(); @@ -158,8 +171,8 @@ namespace Flow.Launcher.Infrastructure.Image } catch (System.Exception e2) { - Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on first try", e); - Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on second try", e2); + Log.Exception(ClassName, $"Failed to get thumbnail for {path} on first try", e); + Log.Exception(ClassName, $"Failed to get thumbnail for {path} on second try", e2); ImageSource image = ImageCache[Constant.MissingImgIcon, false]; ImageCache[path, false] = image; @@ -173,7 +186,7 @@ namespace Flow.Launcher.Infrastructure.Image private static async Task LoadRemoteImageAsync(bool loadFullImage, Uri uriResult) { // Download image from url - await using var resp = await GetStreamAsync(uriResult); + await using var resp = await Http.Http.GetStreamAsync(uriResult); await using var buffer = new MemoryStream(); await resp.CopyToAsync(buffer); buffer.Seek(0, SeekOrigin.Begin); @@ -221,10 +234,11 @@ namespace Flow.Launcher.Infrastructure.Image image = LoadFullImage(path); type = ImageType.FullImageFile; } - catch (NotSupportedException) + catch (NotSupportedException ex) { image = Image; type = ImageType.Error; + Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); } } else @@ -237,6 +251,20 @@ namespace Flow.Launcher.Infrastructure.Image image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); } } + else if (extension == SvgExtension) + { + try + { + image = LoadSvgImage(path, loadFullImage); + type = ImageType.FullImageFile; + } + catch (System.Exception ex) + { + image = Image; + type = ImageType.Error; + Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); + } + } else { type = ImageType.File; @@ -277,7 +305,7 @@ namespace Flow.Launcher.Infrastructure.Image return ImageCache.TryGetValue(path, loadFullImage, out image); } - public static async ValueTask LoadAsync(string path, bool loadFullImage = false) + public static async ValueTask LoadAsync(string path, bool loadFullImage = false, bool cacheImage = true) { var imageResult = await LoadInternalAsync(path, loadFullImage); @@ -293,22 +321,24 @@ namespace Flow.Launcher.Infrastructure.Image // image already exists img = ImageCache[key, loadFullImage] ?? img; } - else + else if (cacheImage) { - // new guid - + // save guid key GuidToKey[hash] = path; } } - // update cache - ImageCache[path, loadFullImage] = img; + if (cacheImage) + { + // update cache + ImageCache[path, loadFullImage] = img; + } } return img; } - private static BitmapImage LoadFullImage(string path) + private static ImageSource LoadFullImage(string path) { BitmapImage image = new BitmapImage(); image.BeginInit(); @@ -317,24 +347,24 @@ namespace Flow.Launcher.Infrastructure.Image image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; image.EndInit(); - if (image.PixelWidth > 320) + if (image.PixelWidth > FullImageSize) { BitmapImage resizedWidth = new BitmapImage(); resizedWidth.BeginInit(); resizedWidth.CacheOption = BitmapCacheOption.OnLoad; resizedWidth.UriSource = new Uri(path); resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedWidth.DecodePixelWidth = 320; + resizedWidth.DecodePixelWidth = FullImageSize; resizedWidth.EndInit(); - if (resizedWidth.PixelHeight > 320) + if (resizedWidth.PixelHeight > FullImageSize) { BitmapImage resizedHeight = new BitmapImage(); resizedHeight.BeginInit(); resizedHeight.CacheOption = BitmapCacheOption.OnLoad; resizedHeight.UriSource = new Uri(path); resizedHeight.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedHeight.DecodePixelHeight = 320; + resizedHeight.DecodePixelHeight = FullImageSize; resizedHeight.EndInit(); return resizedHeight; } @@ -344,5 +374,50 @@ namespace Flow.Launcher.Infrastructure.Image return image; } + + private static ImageSource LoadSvgImage(string path, bool loadFullImage = false) + { + // Set up drawing settings + var desiredHeight = loadFullImage ? FullImageSize : SmallIconSize; + var drawingSettings = new WpfDrawingSettings + { + IncludeRuntime = true, + // Set IgnoreRootViewbox to false to respect the SVG's viewBox + IgnoreRootViewbox = false + }; + + // Load and render the SVG + var converter = new FileSvgReader(drawingSettings); + var drawing = converter.Read(new Uri(path)); + + // Calculate scale to achieve desired height + var drawingBounds = drawing.Bounds; + if (drawingBounds.Height <= 0) + { + throw new InvalidOperationException($"Invalid SVG dimensions: Height must be greater than zero in {path}"); + } + var scale = desiredHeight / drawingBounds.Height; + var scaledWidth = drawingBounds.Width * scale; + var scaledHeight = drawingBounds.Height * scale; + + // Convert the Drawing to a Bitmap + var drawingVisual = new DrawingVisual(); + using (DrawingContext drawingContext = drawingVisual.RenderOpen()) + { + drawingContext.PushTransform(new ScaleTransform(scale, scale)); + drawingContext.DrawDrawing(drawing); + } + + // Create a RenderTargetBitmap to hold the rendered image + var bitmap = new RenderTargetBitmap( + (int)Math.Ceiling(scaledWidth), + (int)Math.Ceiling(scaledHeight), + 96, // DpiX + 96, // DpiY + PixelFormats.Pbgra32); + bitmap.Render(drawingVisual); + + return bitmap; + } } } diff --git a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs index b98ea50fe..4ce0df026 100644 --- a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs +++ b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs @@ -12,7 +12,7 @@ using Windows.Win32.Graphics.Gdi; namespace Flow.Launcher.Infrastructure.Image { /// - /// Subclass of + /// Subclass of /// [Flags] public enum ThumbnailOptions @@ -31,7 +31,9 @@ namespace Flow.Launcher.Infrastructure.Image private static readonly Guid GUID_IShellItem = typeof(IShellItem).GUID; - private static readonly HRESULT S_ExtractionFailed = (HRESULT)0x8004B200; + private static readonly HRESULT S_EXTRACTIONFAILED = (HRESULT)0x8004B200; + + private static readonly HRESULT S_PATHNOTFOUND = (HRESULT)0x8004B205; public static BitmapSource GetThumbnail(string fileName, int width, int height, ThumbnailOptions options) { @@ -79,9 +81,10 @@ namespace Flow.Launcher.Infrastructure.Image { imageFactory.GetImage(size, (SIIGBF)options, &hBitmap); } - catch (COMException ex) when (ex.HResult == S_ExtractionFailed && options == ThumbnailOptions.ThumbnailOnly) + catch (COMException ex) when (options == ThumbnailOptions.ThumbnailOnly && + (ex.HResult == S_PATHNOTFOUND || ex.HResult == S_EXTRACTIONFAILED)) { - // Fallback to IconOnly if ThumbnailOnly fails + // Fallback to IconOnly if extraction fails or files cannot be found imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap); } catch (FileNotFoundException) when (options == ThumbnailOptions.ThumbnailOnly) @@ -89,6 +92,11 @@ namespace Flow.Launcher.Infrastructure.Image // Fallback to IconOnly if files cannot be found imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap); } + catch (System.Exception ex) + { + // Handle other exceptions + throw new InvalidOperationException("Failed to get thumbnail", ex); + } } finally { diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index 7f847e287..09eb98f46 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -1,24 +1,24 @@ using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using Flow.Launcher.Infrastructure.UserSettings; using NLog; using NLog.Config; using NLog.Targets; -using Flow.Launcher.Infrastructure.UserSettings; using NLog.Targets.Wrappers; -using System.Runtime.ExceptionServices; namespace Flow.Launcher.Infrastructure.Logger { public static class Log { - public const string DirectoryName = "Logs"; + public const string DirectoryName = Constant.Logs; public static string CurrentLogDirectory { get; } static Log() { - CurrentLogDirectory = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Version); + CurrentLogDirectory = DataLocation.VersionLogDirectory; if (!Directory.Exists(CurrentLogDirectory)) { Directory.CreateDirectory(CurrentLogDirectory); @@ -94,13 +94,6 @@ namespace Flow.Launcher.Infrastructure.Logger logger.Fatal(message); } - private static bool FormatValid(string message) - { - var parts = message.Split('|'); - var valid = parts.Length == 3 && !string.IsNullOrWhiteSpace(parts[1]) && !string.IsNullOrWhiteSpace(parts[2]); - return valid; - } - public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "") { exception = exception.Demystify(); @@ -135,57 +128,14 @@ namespace Flow.Launcher.Infrastructure.Logger return className; } +#if !DEBUG private static void ExceptionInternal(string classAndMethod, string message, System.Exception e) { var logger = LogManager.GetLogger(classAndMethod); logger.Error(e, message); } - - private static void LogInternal(string message, LogLevel level) - { - if (FormatValid(message)) - { - var parts = message.Split('|'); - var prefix = parts[1]; - var unprefixed = parts[2]; - var logger = LogManager.GetLogger(prefix); - logger.Log(level, unprefixed); - } - else - { - LogFaultyFormat(message); - } - } - - /// Example: "|ClassName.MethodName|Message" - /// Example: "|ClassName.MethodName|Message" - /// Exception - public static void Exception(string message, System.Exception e) - { - e = e.Demystify(); -#if DEBUG - ExceptionDispatchInfo.Capture(e).Throw(); -#else - if (FormatValid(message)) - { - var parts = message.Split('|'); - var prefix = parts[1]; - var unprefixed = parts[2]; - ExceptionInternal(prefix, unprefixed, e); - } - else - { - LogFaultyFormat(message); - } #endif - } - - /// Example: "|ClassName.MethodName|Message" - public static void Error(string message) - { - LogInternal(message, LogLevel.Error); - } public static void Error(string className, string message, [CallerMemberName] string methodName = "") { @@ -206,33 +156,15 @@ namespace Flow.Launcher.Infrastructure.Logger LogInternal(LogLevel.Debug, className, message, methodName); } - /// Example: "|ClassName.MethodName|Message"" - public static void Debug(string message) - { - LogInternal(message, LogLevel.Debug); - } - public static void Info(string className, string message, [CallerMemberName] string methodName = "") { LogInternal(LogLevel.Info, className, message, methodName); } - /// Example: "|ClassName.MethodName|Message" - public static void Info(string message) - { - LogInternal(message, LogLevel.Info); - } - public static void Warn(string className, string message, [CallerMemberName] string methodName = "") { LogInternal(LogLevel.Warn, className, message, methodName); } - - /// Example: "|ClassName.MethodName|Message" - public static void Warn(string message) - { - LogInternal(message, LogLevel.Warn); - } } public enum LOGLEVEL diff --git a/Flow.Launcher.Infrastructure/MonitorInfo.cs b/Flow.Launcher.Infrastructure/MonitorInfo.cs new file mode 100644 index 000000000..3221708c1 --- /dev/null +++ b/Flow.Launcher.Infrastructure/MonitorInfo.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System; +using System.Runtime.InteropServices; +using System.Windows; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Flow.Launcher.Infrastructure; + +/// +/// Contains full information about a display monitor. +/// Codes are edited from: . +/// +internal class MonitorInfo +{ + /// + /// Gets the display monitors (including invisible pseudo-monitors associated with the mirroring drivers). + /// + /// A list of display monitors + public static unsafe IList GetDisplayMonitors() + { + var monitorCount = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CMONITORS); + var list = new List(monitorCount); + var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + { + list.Add(new MonitorInfo(monitor, rect)); + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return list; + } + + /// + /// Gets the display monitor that is nearest to a given window. + /// + /// Window handle + /// The display monitor that is nearest to a given window, or null if no monitor is found. + public static unsafe MonitorInfo GetNearestDisplayMonitor(HWND hwnd) + { + var nearestMonitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + MonitorInfo nearestMonitorInfo = null; + var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + { + if (monitor == nearestMonitor) + { + nearestMonitorInfo = new MonitorInfo(monitor, rect); + return false; + } + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return nearestMonitorInfo; + } + + private readonly HMONITOR _monitor; + + internal unsafe MonitorInfo(HMONITOR monitor, RECT* rect) + { + RectMonitor = + new Rect(new Point(rect->left, rect->top), + new Point(rect->right, rect->bottom)); + _monitor = monitor; + var info = new MONITORINFOEXW() { monitorInfo = new MONITORINFO() { cbSize = (uint)sizeof(MONITORINFOEXW) } }; + GetMonitorInfo(monitor, ref info); + RectWork = + new Rect(new Point(info.monitorInfo.rcWork.left, info.monitorInfo.rcWork.top), + new Point(info.monitorInfo.rcWork.right, info.monitorInfo.rcWork.bottom)); + Name = new string(info.szDevice.AsSpan()).Replace("\0", "").Trim(); + } + + /// + /// Gets the name of the display. + /// + public string Name { get; } + + /// + /// Gets the display monitor rectangle, expressed in virtual-screen coordinates. + /// + /// + /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. + /// + public Rect RectMonitor { get; } + + /// + /// Gets the work area rectangle of the display monitor, expressed in virtual-screen coordinates. + /// + /// + /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. + /// + public Rect RectWork { get; } + + /// + /// Gets if the monitor is the the primary display monitor. + /// + public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(IntPtr.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); + + /// + public override string ToString() => $"{Name} {RectMonitor.Width}x{RectMonitor.Height}"; + + private static unsafe bool GetMonitorInfo(HMONITOR hMonitor, ref MONITORINFOEXW lpmi) + { + fixed (MONITORINFOEXW* lpmiLocal = &lpmi) + { + var lpmiBase = (MONITORINFO*)lpmiLocal; + var __result = PInvoke.GetMonitorInfo(hMonitor, lpmiBase); + return __result; + } + } +} diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index f117534a1..edc71feef 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -11,9 +11,58 @@ GetModuleHandle GetKeyState VIRTUAL_KEY -WM_KEYDOWN -WM_KEYUP -WM_SYSKEYDOWN -WM_SYSKEYUP +EnumWindows -EnumWindows \ No newline at end of file +DwmSetWindowAttribute +DWM_SYSTEMBACKDROP_TYPE +DWM_WINDOW_CORNER_PREFERENCE + +MAX_PATH +SystemParametersInfo + +SetForegroundWindow + +WINDOW_LONG_PTR_INDEX +GetForegroundWindow +GetDesktopWindow +GetShellWindow +GetWindowRect +GetClassName +FindWindowEx +WINDOW_STYLE + +SetLastError +WINDOW_EX_STYLE + +GetSystemMetrics +EnumDisplayMonitors +MonitorFromWindow +GetMonitorInfo +MONITORINFOEXW + +WM_ENTERSIZEMOVE +WM_EXITSIZEMOVE +WM_NCLBUTTONDBLCLK +WM_SYSCOMMAND + +SC_MAXIMIZE +SC_MINIMIZE + +OleInitialize +OleUninitialize + +GetKeyboardLayout +GetWindowThreadProcessId +ActivateKeyboardLayout +GetKeyboardLayoutList +PostMessage +WM_INPUTLANGCHANGEREQUEST +INPUTLANGCHANGE_FORWARD +LOCALE_TRANSIENT_KEYBOARD1 +LOCALE_TRANSIENT_KEYBOARD2 +LOCALE_TRANSIENT_KEYBOARD3 +LOCALE_TRANSIENT_KEYBOARD4 + +SHParseDisplayName +SHOpenFolderAndSelectItems +CoTaskMemFree diff --git a/Flow.Launcher.Infrastructure/PInvokeExtensions.cs b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs new file mode 100644 index 000000000..18b992043 --- /dev/null +++ b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Windows.Win32; + +internal static partial class PInvoke +{ + // SetWindowLong + // Edited from: https://github.com/files-community/Files + + [DllImport("User32", EntryPoint = "SetWindowLongW", ExactSpelling = true)] + private static extern int _SetWindowLong(HWND hWnd, int nIndex, int dwNewLong); + + [DllImport("User32", EntryPoint = "SetWindowLongPtrW", ExactSpelling = true)] + private static extern nint _SetWindowLongPtr(HWND hWnd, int nIndex, nint dwNewLong); + + // NOTE: + // CsWin32 doesn't generate SetWindowLong on other than x86 and vice versa. + // For more info, visit https://github.com/microsoft/CsWin32/issues/882 + public static unsafe nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong) + { + return sizeof(nint) is 4 + ? _SetWindowLong(hWnd, (int)nIndex, (int)dwNewLong) + : _SetWindowLongPtr(hWnd, (int)nIndex, dwNewLong); + } + + // GetWindowLong + + [DllImport("User32", EntryPoint = "GetWindowLongW", ExactSpelling = true)] + private static extern int _GetWindowLong(HWND hWnd, int nIndex); + + [DllImport("User32", EntryPoint = "GetWindowLongPtrW", ExactSpelling = true)] + private static extern nint _GetWindowLongPtr(HWND hWnd, int nIndex); + + // NOTE: + // CsWin32 doesn't generate GetWindowLong on other than x86 and vice versa. + // For more info, visit https://github.com/microsoft/CsWin32/issues/882 + public static unsafe nint GetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex) + { + return sizeof(nint) is 4 + ? _GetWindowLong(hWnd, (int)nIndex) + : _GetWindowLongPtr(hWnd, (int)nIndex); + } +} diff --git a/Flow.Launcher.Infrastructure/Stopwatch.cs b/Flow.Launcher.Infrastructure/Stopwatch.cs index dd6edaff9..870e0fe26 100644 --- a/Flow.Launcher.Infrastructure/Stopwatch.cs +++ b/Flow.Launcher.Infrastructure/Stopwatch.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; @@ -7,91 +7,54 @@ namespace Flow.Launcher.Infrastructure { public static class Stopwatch { - private static readonly Dictionary Count = new Dictionary(); - private static readonly object Locker = new object(); /// /// This stopwatch will appear only in Debug mode /// - public static long Debug(string message, Action action) + public static long Debug(string className, string message, Action action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Debug(info); + Log.Debug(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } /// /// This stopwatch will appear only in Debug mode /// - public static async Task DebugAsync(string message, Func action) + public static async Task DebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); await action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Debug(info); + Log.Debug(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - public static long Normal(string message, Action action) + public static long Info(string className, string message, Action action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Info(info); + Log.Info(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - public static async Task NormalAsync(string message, Func action) + public static async Task InfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = "") { var stopWatch = new System.Diagnostics.Stopwatch(); stopWatch.Start(); await action(); stopWatch.Stop(); var milliseconds = stopWatch.ElapsedMilliseconds; - string info = $"{message} <{milliseconds}ms>"; - Log.Info(info); + Log.Info(className, $"{message} <{milliseconds}ms>", methodName); return milliseconds; } - - - - public static void StartCount(string name, Action action) - { - var stopWatch = new System.Diagnostics.Stopwatch(); - stopWatch.Start(); - action(); - stopWatch.Stop(); - var milliseconds = stopWatch.ElapsedMilliseconds; - lock (Locker) - { - if (Count.ContainsKey(name)) - { - Count[name] += milliseconds; - } - else - { - Count[name] = 0; - } - } - } - - public static void EndCount() - { - foreach (var key in Count.Keys) - { - string info = $"{key} already cost {Count[key]}ms"; - Log.Debug(info); - } - } } } diff --git a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs index 2a439b8cc..48e6b5523 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -1,14 +1,14 @@ using System; using System.IO; -using System.Reflection; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Formatters; -using System.Runtime.Serialization.Formatters.Binary; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; using MemoryPack; +#nullable enable + namespace Flow.Launcher.Infrastructure.Storage { /// @@ -16,64 +16,111 @@ namespace Flow.Launcher.Infrastructure.Storage /// Normally, it has better performance, but not readable /// /// - /// It utilize MemoryPack, which means the object must be MemoryPackSerializable - /// https://github.com/Cysharp/MemoryPack + /// It utilizes MemoryPack, which means the object must be MemoryPackSerializable /// - public class BinaryStorage + public class BinaryStorage : ISavable { - const string DirectoryName = "Cache"; + private static readonly string ClassName = "BinaryStorage"; - const string FileSuffix = ".cache"; + protected T? Data; + + public const string FileSuffix = ".cache"; + + protected string FilePath { get; init; } = null!; + + protected string DirectoryPath { get; init; } = null!; + + // Let the derived class to set the file path + protected BinaryStorage() + { + } public BinaryStorage(string filename) { - var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); - Helper.ValidateDirectory(directoryPath); + DirectoryPath = DataLocation.CacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); - FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); } - public string FilePath { get; } + // Let the old Program plugin get this constructor + [Obsolete("This constructor is obsolete. Use BinaryStorage(string filename) instead.")] + public BinaryStorage(string filename, string directoryPath = null!) + { + DirectoryPath = directoryPath ?? DataLocation.CacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); + + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); + } public async ValueTask TryLoadAsync(T defaultData) { + if (Data != null) return Data; + if (File.Exists(FilePath)) { if (new FileInfo(FilePath).Length == 0) { - Log.Error($"|BinaryStorage.TryLoad|Zero length cache file <{FilePath}>"); - await SaveAsync(defaultData); - return defaultData; + Log.Error(ClassName, $"Zero length cache file <{FilePath}>"); + Data = defaultData; + await SaveAsync(); } await using var stream = new FileStream(FilePath, FileMode.Open); - var d = await DeserializeAsync(stream, defaultData); - return d; + Data = await DeserializeAsync(stream, defaultData); } else { - Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data"); - await SaveAsync(defaultData); - return defaultData; + Log.Info(ClassName, "Cache file not exist, load default data"); + Data = defaultData; + await SaveAsync(); } + + return Data; } - private async ValueTask DeserializeAsync(Stream stream, T defaultData) + private static async ValueTask DeserializeAsync(Stream stream, T defaultData) { try { var t = await MemoryPackSerializer.DeserializeAsync(stream); - return t; + return t ?? defaultData; } - catch (System.Exception e) + catch (System.Exception) { // Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); return defaultData; } } + public void Save() + { + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + + var serialized = MemoryPackSerializer.Serialize(Data); + File.WriteAllBytes(FilePath, serialized); + } + + public async ValueTask SaveAsync() + { + await SaveAsync(Data.NonNull()); + } + + // ImageCache need to convert data into concurrent dictionary for usage, + // so we would better to clear the data + public void ClearData() + { + Data = default; + } + + // ImageCache storages data in its class, + // so we need to pass it to SaveAsync public async ValueTask SaveAsync(T data) { + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + await using var stream = new FileStream(FilePath, FileMode.Create); await MemoryPackSerializer.SerializeAsync(stream, data); } diff --git a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs index 865041fb3..158e0cdf5 100644 --- a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs @@ -1,17 +1,46 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { public class FlowLauncherJsonStorage : JsonStorage where T : new() { + private static readonly string ClassName = "FlowLauncherJsonStorage"; + public FlowLauncherJsonStorage() { - var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); - Helper.ValidateDirectory(directoryPath); + DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); + FilesFolders.ValidateDirectory(DirectoryPath); var filename = typeof(T).Name; - FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); + FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}"); + } + + public new void Save() + { + try + { + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e); + } } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs index 507838d94..c7eba05fd 100644 --- a/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/JsonStorage.cs @@ -1,22 +1,27 @@ -#nullable enable -using System; +using System; using System.Globalization; using System.IO; using System.Text.Json; using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; + +#nullable enable namespace Flow.Launcher.Infrastructure.Storage { /// /// Serialize object using json format. /// - public class JsonStorage where T : new() + public class JsonStorage : ISavable where T : new() { + private static readonly string ClassName = "JsonStorage"; + protected T? Data; // need a new directory name - public const string DirectoryName = "Settings"; + public const string DirectoryName = Constant.Settings; public const string FileSuffix = ".json"; protected string FilePath { get; init; } = null!; @@ -37,7 +42,23 @@ namespace Flow.Launcher.Infrastructure.Storage FilePath = filePath; DirectoryPath = Path.GetDirectoryName(filePath) ?? throw new ArgumentException("Invalid file path"); - Helper.ValidateDirectory(DirectoryPath); + FilesFolders.ValidateDirectory(DirectoryPath); + } + + public bool Exists() + { + return File.Exists(FilePath); + } + + public void Delete() + { + foreach (var path in new[] { FilePath, BackupFilePath, TempFilePath }) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } } public async Task LoadAsync() @@ -101,7 +122,7 @@ namespace Flow.Launcher.Infrastructure.Storage private void RestoreBackup() { - Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully"); + Log.Info(ClassName, $"Failed to load settings.json, {BackupFilePath} restored successfully"); if (File.Exists(FilePath)) File.Replace(BackupFilePath, FilePath, null); @@ -180,7 +201,10 @@ namespace Flow.Launcher.Infrastructure.Storage public void Save() { - string serialized = JsonSerializer.Serialize(Data, + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + + var serialized = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(TempFilePath, serialized); @@ -190,6 +214,9 @@ namespace Flow.Launcher.Infrastructure.Storage public async Task SaveAsync() { + // User may delete the directory, so we need to check it + FilesFolders.ValidateDirectory(DirectoryPath); + await using var tempOutput = File.OpenWrite(TempFilePath); await JsonSerializer.SerializeAsync(tempOutput, Data, new JsonSerializerOptions { WriteIndented = true }); diff --git a/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs new file mode 100644 index 000000000..01da96d62 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin.SharedCommands; + +namespace Flow.Launcher.Infrastructure.Storage +{ + public class PluginBinaryStorage : BinaryStorage where T : new() + { + private static readonly string ClassName = "PluginBinaryStorage"; + + public PluginBinaryStorage(string cacheName, string cacheDirectory) + { + DirectoryPath = cacheDirectory; + FilesFolders.ValidateDirectory(DirectoryPath); + + FilePath = Path.Combine(DirectoryPath, $"{cacheName}{FileSuffix}"); + } + + public new void Save() + { + try + { + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e); + } + } + } +} diff --git a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs index bc3900da8..147152949 100644 --- a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs @@ -1,5 +1,8 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { @@ -8,13 +11,15 @@ namespace Flow.Launcher.Infrastructure.Storage // Use assembly name to check which plugin is using this storage public readonly string AssemblyName; + private static readonly string ClassName = "PluginJsonStorage"; + public PluginJsonStorage() { // C# related, add python related below var dataType = typeof(T); AssemblyName = dataType.Assembly.GetName().Name; - DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Plugins, AssemblyName); - Helper.ValidateDirectory(DirectoryPath); + DirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, AssemblyName); + FilesFolders.ValidateDirectory(DirectoryPath); FilePath = Path.Combine(DirectoryPath, $"{dataType.Name}{FileSuffix}"); } @@ -24,11 +29,27 @@ namespace Flow.Launcher.Infrastructure.Storage Data = data; } - public void DeleteDirectory() + public new void Save() { - if (Directory.Exists(DirectoryPath)) + try { - Directory.Delete(DirectoryPath, true); + base.Save(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e); + } + } + + public new async Task SaveAsync() + { + try + { + await base.SaveAsync(); + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e); } } } diff --git a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs b/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs index 350c892cf..f9504e6d9 100644 --- a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs +++ b/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs @@ -3,6 +3,7 @@ using System.Windows.Markup; namespace Flow.Launcher.Infrastructure.UI { + [Obsolete("EnumBindingSourceExtension is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] public class EnumBindingSourceExtension : MarkupExtension { private Type _enumType; diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs index 71020369a..2d15b54c5 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,15 +1,15 @@ using System; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace Flow.Launcher.Infrastructure.UserSettings { + #region Base + public abstract class ShortcutBaseModel { public string Key { get; set; } - [JsonIgnore] - public Func Expand { get; set; } = () => { return ""; }; - public override bool Equals(object obj) { return obj is ShortcutBaseModel other && @@ -22,16 +22,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } - public class CustomShortcutModel : ShortcutBaseModel + public class BaseCustomShortcutModel : ShortcutBaseModel { public string Value { get; set; } - [JsonConstructorAttribute] - public CustomShortcutModel(string key, string value) + public BaseCustomShortcutModel(string key, string value) { Key = key; Value = value; - Expand = () => { return Value; }; } public void Deconstruct(out string key, out string value) @@ -40,26 +38,69 @@ namespace Flow.Launcher.Infrastructure.UserSettings value = Value; } - public static implicit operator (string Key, string Value)(CustomShortcutModel shortcut) + public static implicit operator (string Key, string Value)(BaseCustomShortcutModel shortcut) { return (shortcut.Key, shortcut.Value); } - public static implicit operator CustomShortcutModel((string Key, string Value) shortcut) + public static implicit operator BaseCustomShortcutModel((string Key, string Value) shortcut) { - return new CustomShortcutModel(shortcut.Key, shortcut.Value); + return new BaseCustomShortcutModel(shortcut.Key, shortcut.Value); } } - public class BuiltinShortcutModel : ShortcutBaseModel + public class BaseBuiltinShortcutModel : ShortcutBaseModel { public string Description { get; set; } - public BuiltinShortcutModel(string key, string description, Func expand) + public BaseBuiltinShortcutModel(string key, string description) { Key = key; Description = description; - Expand = expand ?? (() => { return ""; }); } } + + #endregion + + #region Custom Shortcut + + public class CustomShortcutModel : BaseCustomShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + [JsonConstructor] + public CustomShortcutModel(string key, string value) : base(key, value) + { + Expand = () => { return Value; }; + } + } + + #endregion + + #region Builtin Shortcut + + public class BuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + public BuiltinShortcutModel(string key, string description, Func expand) : base(key, description) + { + Expand = expand ?? (() => { return string.Empty; }); + } + } + + public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); }; + + public AsyncBuiltinShortcutModel(string key, string description, Func> expandAsync) : base(key, description) + { + ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); }); + } + } + + #endregion } diff --git a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs index e294f52b8..5b948e450 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs @@ -25,8 +25,16 @@ namespace Flow.Launcher.Infrastructure.UserSettings return false; } + public static string VersionLogDirectory => Path.Combine(LogDirectory, Constant.Version); + public static string LogDirectory => Path.Combine(DataDirectory(), Constant.Logs); + + public static readonly string CacheDirectory = Path.Combine(DataDirectory(), Constant.Cache); + public static readonly string SettingsDirectory = Path.Combine(DataDirectory(), Constant.Settings); public static readonly string PluginsDirectory = Path.Combine(DataDirectory(), Constant.Plugins); - public static readonly string PluginSettingsDirectory = Path.Combine(DataDirectory(), "Settings", Constant.Plugins); + public static readonly string ThemesDirectory = Path.Combine(DataDirectory(), Constant.Themes); + + public static readonly string PluginSettingsDirectory = Path.Combine(SettingsDirectory, Constant.Plugins); + public static readonly string PluginCacheDirectory = Path.Combine(DataDirectory(), Constant.Cache, Constant.Plugins); public const string PythonEnvironmentName = "Python"; public const string NodeEnvironmentName = "Node.js"; diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index 98f4dccda..920abc284 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; using Flow.Launcher.Plugin; namespace Flow.Launcher.Infrastructure.UserSettings @@ -6,8 +7,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings public class PluginsSettings : BaseModel { private string pythonExecutablePath = string.Empty; - public string PythonExecutablePath { - get { return pythonExecutablePath; } + public string PythonExecutablePath + { + get => pythonExecutablePath; set { pythonExecutablePath = value; @@ -18,7 +20,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings private string nodeExecutablePath = string.Empty; public string NodeExecutablePath { - get { return nodeExecutablePath; } + get => nodeExecutablePath; set { nodeExecutablePath = value; @@ -26,19 +28,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } - public Dictionary Plugins { get; set; } = new Dictionary(); + /// + /// Only used for serialization + /// + public Dictionary Plugins { get; set; } = new(); + /// + /// Update plugin settings with metadata. + /// FL will get default values from metadata first and then load settings to metadata + /// + /// Parsed plugin metadatas public void UpdatePluginSettings(List metadatas) { foreach (var metadata in metadatas) { - if (Plugins.ContainsKey(metadata.ID)) + if (Plugins.TryGetValue(metadata.ID, out var settings)) { - var settings = Plugins[metadata.ID]; - + // If settings exist, update settings & metadata value + // update settings values with metadata if (string.IsNullOrEmpty(settings.Version)) + { settings.Version = metadata.Version; + } + settings.DefaultActionKeywords = metadata.ActionKeywords; // metadata provides default values + settings.DefaultSearchDelayTime = metadata.SearchDelayTime; // metadata provides default values + // update metadata values with settings if (settings.ActionKeywords?.Count > 0) { metadata.ActionKeywords = settings.ActionKeywords; @@ -51,33 +66,70 @@ namespace Flow.Launcher.Infrastructure.UserSettings } metadata.Disabled = settings.Disabled; metadata.Priority = settings.Priority; + metadata.SearchDelayTime = settings.SearchDelayTime; + metadata.HomeDisabled = settings.HomeDisabled; } else { + // If settings does not exist, create a new one Plugins[metadata.ID] = new Plugin { ID = metadata.ID, Name = metadata.Name, Version = metadata.Version, - ActionKeywords = metadata.ActionKeywords, + DefaultActionKeywords = metadata.ActionKeywords, // metadata provides default values + ActionKeywords = metadata.ActionKeywords, // use default value Disabled = metadata.Disabled, - Priority = metadata.Priority + HomeDisabled = metadata.HomeDisabled, + Priority = metadata.Priority, + DefaultSearchDelayTime = metadata.SearchDelayTime, // metadata provides default values + SearchDelayTime = metadata.SearchDelayTime, // use default value }; } } } + + public Plugin GetPluginSettings(string id) + { + if (Plugins.TryGetValue(id, out var plugin)) + { + return plugin; + } + return null; + } + + public Plugin RemovePluginSettings(string id) + { + Plugins.Remove(id, out var plugin); + return plugin; + } } + public class Plugin { public string ID { get; set; } + public string Name { get; set; } + public string Version { get; set; } - public List ActionKeywords { get; set; } // a reference of the action keywords from plugin manager + + [JsonIgnore] + public List DefaultActionKeywords { get; set; } + + // a reference of the action keywords from plugin manager + public List ActionKeywords { get; set; } + public int Priority { get; set; } + [JsonIgnore] + public int? DefaultSearchDelayTime { get; set; } + + public int? SearchDelayTime { get; set; } + /// /// Used only to save the state of the plugin in settings /// public bool Disabled { get; set; } + public bool HomeDisabled { get; set; } } } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 505eb4d3c..fe943d699 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Drawing; using System.Text.Json.Serialization; using System.Windows; +using System.Windows.Media; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Logger; @@ -33,7 +33,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings _storage.Save(); } - private string language = Constant.SystemLanguageCode; private string _theme = Constant.DefaultTheme; public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {KeyConstant.Space}"; public string OpenResultModifiers { get; set; } = KeyConstant.Alt; @@ -51,15 +50,17 @@ namespace Flow.Launcher.Infrastructure.UserSettings public string SelectPrevPageHotkey { get; set; } = $"PageDown"; public string OpenContextMenuHotkey { get; set; } = $"Ctrl+O"; public string SettingWindowHotkey { get; set; } = $"Ctrl+I"; + public string OpenHistoryHotkey { get; set; } = $"Ctrl+H"; public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up"; public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down"; + private string _language = Constant.SystemLanguageCode; public string Language { - get => language; + get => _language; set { - language = value; + _language = value; OnPropertyChanged(); } } @@ -68,30 +69,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _theme; set { - if (value == _theme) - return; - _theme = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(MaxResultsToShow)); + if (value != _theme) + { + _theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(MaxResultsToShow)); + } } } public bool UseDropShadowEffect { get; set; } = true; + public BackdropTypes BackdropType{ get; set; } = BackdropTypes.None; /* Appearance Settings. It should be separated from the setting later.*/ public double WindowHeightSize { get; set; } = 42; public double ItemHeightSize { get; set; } = 58; - public double QueryBoxFontSize { get; set; } = 20; + public double QueryBoxFontSize { get; set; } = 16; public double ResultItemFontSize { get; set; } = 16; public double ResultSubItemFontSize { get; set; } = 13; - public string QueryBoxFont { get; set; } = FontFamily.GenericSansSerif.Name; + public string QueryBoxFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string QueryBoxFontStyle { get; set; } public string QueryBoxFontWeight { get; set; } public string QueryBoxFontStretch { get; set; } - public string ResultFont { get; set; } = FontFamily.GenericSansSerif.Name; + public string ResultFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string ResultFontStyle { get; set; } public string ResultFontWeight { get; set; } public string ResultFontStretch { get; set; } - public string ResultSubFont { get; set; } = FontFamily.GenericSansSerif.Name; + public string ResultSubFont { get; set; } = Win32Helper.GetSystemDefaultFont(); public string ResultSubFontStyle { get; set; } public string ResultSubFontWeight { get; set; } public string ResultSubFontStretch { get; set; } @@ -99,6 +102,24 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool UseAnimation { get; set; } = true; public bool UseSound { get; set; } = true; public double SoundVolume { get; set; } = 50; + public bool ShowBadges { get; set; } = false; + public bool ShowBadgesGlobalOnly { get; set; } = false; + + private string _settingWindowFont { get; set; } = Win32Helper.GetSystemDefaultFont(false); + public string SettingWindowFont + { + get => _settingWindowFont; + set + { + if (_settingWindowFont != value) + { + _settingWindowFont = value; + OnPropertyChanged(); + Application.Current.Resources["SettingWindowFont"] = new FontFamily(value); + Application.Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(value); + } + } + } public bool UseClock { get; set; } = true; public bool UseDate { get; set; } = false; @@ -110,7 +131,64 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double SettingWindowHeight { get; set; } = 700; public double? SettingWindowTop { get; set; } = null; public double? SettingWindowLeft { get; set; } = null; - public System.Windows.WindowState SettingWindowState { get; set; } = WindowState.Normal; + public WindowState SettingWindowState { get; set; } = WindowState.Normal; + + private bool _showPlaceholder { get; set; } = true; + public bool ShowPlaceholder + { + get => _showPlaceholder; + set + { + if (_showPlaceholder != value) + { + _showPlaceholder = value; + OnPropertyChanged(); + } + } + } + private string _placeholderText { get; set; } = string.Empty; + public string PlaceholderText + { + get => _placeholderText; + set + { + if (_placeholderText != value) + { + _placeholderText = value; + OnPropertyChanged(); + } + } + } + + private bool _showHomePage { get; set; } = true; + public bool ShowHomePage + { + get => _showHomePage; + set + { + if (_showHomePage != value) + { + _showHomePage = value; + OnPropertyChanged(); + } + } + } + + private bool _showHistoryResultsForHomePage = false; + public bool ShowHistoryResultsForHomePage + { + get => _showHistoryResultsForHomePage; + set + { + if (_showHistoryResultsForHomePage != value) + { + _showHistoryResultsForHomePage = value; + OnPropertyChanged(); + } + } + } + + public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; public int CustomExplorerIndex { get; set; } = 0; @@ -149,8 +227,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings new() { Name = "Files", - Path = "Files", - DirectoryArgument = "-select \"%d\"", + Path = "Files-Stable", + DirectoryArgument = "\"%d\"", FileArgument = "-select \"%f\"" } }; @@ -229,6 +307,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings public double WindowLeft { get; set; } public double WindowTop { get; set; } + public double PreviousScreenWidth { get; set; } + public double PreviousScreenHeight { get; set; } + public double PreviousDpiX { get; set; } + public double PreviousDpiY { get; set; } /// /// Custom left position on selected monitor @@ -240,19 +322,35 @@ namespace Flow.Launcher.Infrastructure.UserSettings /// public double CustomWindowTop { get; set; } = 0; - public bool KeepMaxResults { get; set; } = false; - public int MaxResultsToShow { get; set; } = 5; - public int ActivateTimes { get; set; } + /// + /// Fixed window size + /// + private bool _keepMaxResults { get; set; } = false; + public bool KeepMaxResults + { + get => _keepMaxResults; + set + { + if (_keepMaxResults != value) + { + _keepMaxResults = value; + OnPropertyChanged(); + } + } + } + public int MaxResultsToShow { get; set; } = 5; + + public int ActivateTimes { get; set; } public ObservableCollection CustomPluginHotkeys { get; set; } = new ObservableCollection(); public ObservableCollection CustomShortcuts { get; set; } = new ObservableCollection(); [JsonIgnore] - public ObservableCollection BuiltinShortcuts { get; set; } = new() + public ObservableCollection BuiltinShortcuts { get; set; } = new() { - new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText), + new AsyncBuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", () => Win32Helper.StartSTATaskAsync(Clipboard.GetText)), new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath) }; @@ -262,10 +360,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool StartFlowLauncherOnSystemStartup { get; set; } = false; public bool UseLogonTaskForStartup { get; set; } = false; public bool HideOnStartup { get; set; } = true; - bool _hideNotifyIcon { get; set; } + private bool _hideNotifyIcon; public bool HideNotifyIcon { - get { return _hideNotifyIcon; } + get => _hideNotifyIcon; set { _hideNotifyIcon = value; @@ -275,6 +373,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool LeaveCmdOpen { get; set; } public bool HideWhenDeactivated { get; set; } = true; + public bool SearchQueryResultsWithDelay { get; set; } + public int SearchDelayTime { get; set; } = 150; + [JsonConverter(typeof(JsonStringEnumConverter))] public SearchWindowScreens SearchWindowScreen { get; set; } = SearchWindowScreens.Cursor; @@ -310,29 +411,31 @@ namespace Flow.Launcher.Infrastructure.UserSettings var list = FixedHotkeys(); // Customizeable hotkeys - if(!string.IsNullOrEmpty(Hotkey)) + if (!string.IsNullOrEmpty(Hotkey)) list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = "")); - if(!string.IsNullOrEmpty(PreviewHotkey)) + if (!string.IsNullOrEmpty(PreviewHotkey)) list.Add(new(PreviewHotkey, "previewHotkey", () => PreviewHotkey = "")); - if(!string.IsNullOrEmpty(AutoCompleteHotkey)) + if (!string.IsNullOrEmpty(AutoCompleteHotkey)) list.Add(new(AutoCompleteHotkey, "autoCompleteHotkey", () => AutoCompleteHotkey = "")); - if(!string.IsNullOrEmpty(AutoCompleteHotkey2)) + if (!string.IsNullOrEmpty(AutoCompleteHotkey2)) list.Add(new(AutoCompleteHotkey2, "autoCompleteHotkey", () => AutoCompleteHotkey2 = "")); - if(!string.IsNullOrEmpty(SelectNextItemHotkey)) + if (!string.IsNullOrEmpty(SelectNextItemHotkey)) list.Add(new(SelectNextItemHotkey, "SelectNextItemHotkey", () => SelectNextItemHotkey = "")); - if(!string.IsNullOrEmpty(SelectNextItemHotkey2)) + if (!string.IsNullOrEmpty(SelectNextItemHotkey2)) list.Add(new(SelectNextItemHotkey2, "SelectNextItemHotkey", () => SelectNextItemHotkey2 = "")); - if(!string.IsNullOrEmpty(SelectPrevItemHotkey)) + if (!string.IsNullOrEmpty(SelectPrevItemHotkey)) list.Add(new(SelectPrevItemHotkey, "SelectPrevItemHotkey", () => SelectPrevItemHotkey = "")); - if(!string.IsNullOrEmpty(SelectPrevItemHotkey2)) + if (!string.IsNullOrEmpty(SelectPrevItemHotkey2)) list.Add(new(SelectPrevItemHotkey2, "SelectPrevItemHotkey", () => SelectPrevItemHotkey2 = "")); - if(!string.IsNullOrEmpty(SettingWindowHotkey)) + if (!string.IsNullOrEmpty(SettingWindowHotkey)) list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = "")); - if(!string.IsNullOrEmpty(OpenContextMenuHotkey)) + if (!string.IsNullOrEmpty(OpenHistoryHotkey)) + list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = "")); + if (!string.IsNullOrEmpty(OpenContextMenuHotkey)) list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = "")); - if(!string.IsNullOrEmpty(SelectNextPageHotkey)) + if (!string.IsNullOrEmpty(SelectNextPageHotkey)) list.Add(new(SelectNextPageHotkey, "SelectNextPageHotkey", () => SelectNextPageHotkey = "")); - if(!string.IsNullOrEmpty(SelectPrevPageHotkey)) + if (!string.IsNullOrEmpty(SelectPrevPageHotkey)) list.Add(new(SelectPrevPageHotkey, "SelectPrevPageHotkey", () => SelectPrevPageHotkey = "")); if (!string.IsNullOrEmpty(CycleHistoryUpHotkey)) list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = "")); @@ -363,7 +466,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings new("Alt+Home", "HotkeySelectFirstResult"), new("Alt+End", "HotkeySelectLastResult"), new("Ctrl+R", "HotkeyRequery"), - new("Ctrl+H", "ToggleHistoryHotkey"), new("Ctrl+OemCloseBrackets", "QuickWidthHotkey"), new("Ctrl+OemOpenBrackets", "QuickWidthHotkey"), new("Ctrl+OemPlus", "QuickHeightHotkey"), @@ -431,4 +533,12 @@ namespace Flow.Launcher.Infrastructure.UserSettings Fast, Custom } + + public enum BackdropTypes + { + None, + Acrylic, + Mica, + MicaAlt + } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 867fef4f5..86e7b7c97 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -1,7 +1,27 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; using System.Runtime.InteropServices; -using System.Windows.Interop; +using System.Threading; +using System.Threading.Tasks; using System.Windows; +using System.Windows.Interop; +using System.Windows.Markup; +using System.Windows.Media; +using Flow.Launcher.Infrastructure.UserSettings; +using Microsoft.Win32; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.Shell.Common; +using Windows.Win32.UI.WindowsAndMessaging; +using Point = System.Windows.Point; +using SystemFonts = System.Windows.SystemFonts; namespace Flow.Launcher.Infrastructure { @@ -9,93 +29,767 @@ namespace Flow.Launcher.Infrastructure { #region Blur Handling - /* - Found on https://github.com/riverar/sample-win10-aeroglass - */ - - private enum AccentState + public static bool IsBackdropSupported() { - ACCENT_DISABLED = 0, - ACCENT_ENABLE_GRADIENT = 1, - ACCENT_ENABLE_TRANSPARENTGRADIENT = 2, - ACCENT_ENABLE_BLURBEHIND = 3, - ACCENT_INVALID_STATE = 4 + // Mica and Acrylic only supported Windows 11 22000+ + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 22000; } - [StructLayout(LayoutKind.Sequential)] - private struct AccentPolicy + public static unsafe bool DWMSetCloakForWindow(Window window, bool cloak) { - public AccentState AccentState; - public int AccentFlags; - public int GradientColor; - public int AnimationId; + var cloaked = cloak ? 1 : 0; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_CLOAK, + &cloaked, + (uint)Marshal.SizeOf()).Succeeded; } - [StructLayout(LayoutKind.Sequential)] - private struct WindowCompositionAttributeData + public static unsafe bool DWMSetBackdropForWindow(Window window, BackdropTypes backdrop) { - public WindowCompositionAttribute Attribute; - public IntPtr Data; - public int SizeOfData; + var backdropType = backdrop switch + { + BackdropTypes.Acrylic => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TRANSIENTWINDOW, + BackdropTypes.Mica => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_MAINWINDOW, + BackdropTypes.MicaAlt => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TABBEDWINDOW, + _ => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_AUTO + }; + + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_SYSTEMBACKDROP_TYPE, + &backdropType, + (uint)Marshal.SizeOf()).Succeeded; } - private enum WindowCompositionAttribute + public static unsafe bool DWMSetDarkModeForWindow(Window window, bool useDarkMode) { - WCA_ACCENT_POLICY = 19 - } + var darkMode = useDarkMode ? 1 : 0; - [DllImport("user32.dll")] - private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, + &darkMode, + (uint)Marshal.SizeOf()).Succeeded; + } /// - /// Checks if the blur theme is enabled + /// /// - public static bool IsBlurTheme() + /// + /// DoNotRound, Round, RoundSmall, Default + /// + public static unsafe bool DWMSetCornerPreferenceForWindow(Window window, string cornerType) { - if (Environment.OSVersion.Version >= new Version(6, 2)) + var preference = cornerType switch { - var resource = Application.Current.TryFindResource("ThemeBlurEnabled"); + "DoNotRound" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DONOTROUND, + "Round" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND, + "RoundSmall" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUNDSMALL, + "Default" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT, + _ => throw new InvalidOperationException("Invalid corner type") + }; - if (resource is bool b) - return b; + return PInvoke.DwmSetWindowAttribute( + GetWindowHandle(window), + DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, + &preference, + (uint)Marshal.SizeOf()).Succeeded; + } + #endregion + + #region Wallpaper + + public static unsafe string GetWallpaperPath() + { + var wallpaperPtr = stackalloc char[(int)PInvoke.MAX_PATH]; + PInvoke.SystemParametersInfo(SYSTEM_PARAMETERS_INFO_ACTION.SPI_GETDESKWALLPAPER, PInvoke.MAX_PATH, + wallpaperPtr, + 0); + var wallpaper = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(wallpaperPtr); + + return wallpaper.ToString(); + } + + #endregion + + #region Window Foreground + + public static nint GetForegroundWindow() + { + return PInvoke.GetForegroundWindow().Value; + } + + public static bool SetForegroundWindow(Window window) + { + return PInvoke.SetForegroundWindow(GetWindowHandle(window)); + } + + public static bool SetForegroundWindow(nint handle) + { + return PInvoke.SetForegroundWindow(new(handle)); + } + + public static bool IsForegroundWindow(Window window) + { + return IsForegroundWindow(GetWindowHandle(window)); + } + + internal static bool IsForegroundWindow(HWND handle) + { + return handle.Equals(PInvoke.GetForegroundWindow()); + } + + #endregion + + #region Task Switching + + /// + /// Hide windows in the Alt+Tab window list + /// + /// To hide a window + public static void HideFromAltTab(Window window) + { + var hwnd = GetWindowHandle(window); + + var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + + // Add TOOLWINDOW style, remove APPWINDOW style + var newExStyle = ((uint)exStyle | (uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) & ~(uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// Restore window display in the Alt+Tab window list. + /// + /// To restore the displayed window + public static void ShowInAltTab(Window window) + { + var hwnd = GetWindowHandle(window); + + var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + + // Remove the TOOLWINDOW style and add the APPWINDOW style. + var newExStyle = ((uint)exStyle & ~(uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) | (uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowStyle(GetWindowHandle(window), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// Disable windows toolbar's control box + /// This will also disable system menu with Alt+Space hotkey + /// + public static void DisableControlBox(Window window) + { + var hwnd = GetWindowHandle(window); + + var style = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE); + + style &= ~(int)WINDOW_STYLE.WS_SYSMENU; + + SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE, style); + } + + private static nint GetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex) + { + var style = PInvoke.GetWindowLongPtr(hWnd, nIndex); + if (style == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + return style; + } + + private static nint SetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong) + { + PInvoke.SetLastError(WIN32_ERROR.NO_ERROR); // Clear any existing error + + var result = PInvoke.SetWindowLongPtr(hWnd, nIndex, dwNewLong); + if (result == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + return result; + } + + #endregion + + #region Window Fullscreen + + private const string WINDOW_CLASS_CONSOLE = "ConsoleWindowClass"; + private const string WINDOW_CLASS_WINTAB = "Flip3D"; + private const string WINDOW_CLASS_PROGMAN = "Progman"; + private const string WINDOW_CLASS_WORKERW = "WorkerW"; + + private static HWND _hwnd_shell; + private static HWND HWND_SHELL => + _hwnd_shell != HWND.Null ? _hwnd_shell : _hwnd_shell = PInvoke.GetShellWindow(); + + private static HWND _hwnd_desktop; + private static HWND HWND_DESKTOP => + _hwnd_desktop != HWND.Null ? _hwnd_desktop : _hwnd_desktop = PInvoke.GetDesktopWindow(); + + public static unsafe bool IsForegroundWindowFullscreen() + { + // Get current active window + var hWnd = PInvoke.GetForegroundWindow(); + if (hWnd.Equals(HWND.Null)) + { return false; } + // If current active window is desktop or shell, exit early + if (hWnd.Equals(HWND_DESKTOP) || hWnd.Equals(HWND_SHELL)) + { + return false; + } + + string windowClass; + const int capacity = 256; + Span buffer = stackalloc char[capacity]; + int validLength; + fixed (char* pBuffer = buffer) + { + validLength = PInvoke.GetClassName(hWnd, pBuffer, capacity); + } + + windowClass = buffer[..validLength].ToString(); + + // For Win+Tab (Flip3D) + if (windowClass == WINDOW_CLASS_WINTAB) + { + return false; + } + + PInvoke.GetWindowRect(hWnd, out var appBounds); + + // For console (ConsoleWindowClass), we have to check for negative dimensions + if (windowClass == WINDOW_CLASS_CONSOLE) + { + return appBounds.top < 0 && appBounds.bottom < 0; + } + + // For desktop (Progman or WorkerW, depends on the system), we have to check + if (windowClass is WINDOW_CLASS_PROGMAN or WINDOW_CLASS_WORKERW) + { + var hWndDesktop = PInvoke.FindWindowEx(hWnd, HWND.Null, "SHELLDLL_DefView", null); + hWndDesktop = PInvoke.FindWindowEx(hWndDesktop, HWND.Null, "SysListView32", "FolderView"); + if (hWndDesktop.Value != IntPtr.Zero) + { + return false; + } + } + + var monitorInfo = MonitorInfo.GetNearestDisplayMonitor(hWnd); + return (appBounds.bottom - appBounds.top) == monitorInfo.RectMonitor.Height && + (appBounds.right - appBounds.left) == monitorInfo.RectMonitor.Width; + } + + #endregion + + #region Pixel to DIP + + /// + /// Transforms pixels to Device Independent Pixels used by WPF + /// + /// current window, required to get presentation source + /// horizontal position in pixels + /// vertical position in pixels + /// point containing device independent pixels + public static Point TransformPixelsToDIP(Visual visual, double unitX, double unitY) + { + Matrix matrix; + var source = PresentationSource.FromVisual(visual); + if (source is not null) + { + matrix = source.CompositionTarget.TransformFromDevice; + } + else + { + using var src = new HwndSource(new HwndSourceParameters()); + matrix = src.CompositionTarget.TransformFromDevice; + } + + return new Point((int)(matrix.M11 * unitX), (int)(matrix.M22 * unitY)); + } + + #endregion + + #region WndProc + + public const int WM_ENTERSIZEMOVE = (int)PInvoke.WM_ENTERSIZEMOVE; + public const int WM_EXITSIZEMOVE = (int)PInvoke.WM_EXITSIZEMOVE; + public const int WM_NCLBUTTONDBLCLK = (int)PInvoke.WM_NCLBUTTONDBLCLK; + public const int WM_SYSCOMMAND = (int)PInvoke.WM_SYSCOMMAND; + + public const int SC_MAXIMIZE = (int)PInvoke.SC_MAXIMIZE; + public const int SC_MINIMIZE = (int)PInvoke.SC_MINIMIZE; + + #endregion + + #region Window Handle + + internal static HWND GetWindowHandle(Window window, bool ensure = false) + { + var windowHelper = new WindowInteropHelper(window); + if (ensure) + { + windowHelper.EnsureHandle(); + } + return new(windowHelper.Handle); + } + + #endregion + + #region STA Thread + + /* + Inspired by https://github.com/files-community/Files code on STA Thread handling. + */ + + public static Task StartSTATaskAsync(Action action) + { + var taskCompletionSource = new TaskCompletionSource(); + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + action(); + taskCompletionSource.SetResult(); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + public static Task StartSTATaskAsync(Func func) + { + var taskCompletionSource = new TaskCompletionSource(); + + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + taskCompletionSource.SetResult(func()); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + #endregion + + #region Keyboard Layout + + private const string UserProfileRegistryPath = @"Control Panel\International\User Profile"; + + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/70feba9f-294e-491e-b6eb-56532684c37f + private const string EnglishLanguageTag = "en"; + + private static readonly string[] ImeLanguageTags = + { + "zh", // Chinese + "ja", // Japanese + "ko", // Korean + }; + + private const uint KeyboardLayoutLoWord = 0xFFFF; + + // Store the previous keyboard layout + private static HKL _previousLayout; + + /// + /// Switches the keyboard layout to English if available. + /// + /// If true, the current keyboard layout will be stored for later restoration. + /// Thrown when there's an error getting the window thread process ID. + public static unsafe void SwitchToEnglishKeyboardLayout(bool backupPrevious) + { + // Find an installed English layout + var enHKL = FindEnglishKeyboardLayout(); + + // No installed English layout found + if (enHKL == HKL.Null) return; + + // Get the foreground window + var hwnd = PInvoke.GetForegroundWindow(); + if (hwnd == HWND.Null) return; + + // Get the current foreground window thread ID + var threadId = PInvoke.GetWindowThreadProcessId(hwnd); + if (threadId == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + + // If the current layout has an IME mode, disable it without switching to another layout. + // This is needed because for languages with IME mode, Flow Launcher just temporarily disables + // the IME mode instead of switching to another layout. + var currentLayout = PInvoke.GetKeyboardLayout(threadId); + var currentLangId = (uint)currentLayout.Value & KeyboardLayoutLoWord; + foreach (var imeLangTag in ImeLanguageTags) + { + var langTag = GetLanguageTag(currentLangId); + if (langTag.StartsWith(imeLangTag, StringComparison.OrdinalIgnoreCase)) return; + } + + // Backup current keyboard layout + if (backupPrevious) _previousLayout = currentLayout; + + // Switch to English layout + PInvoke.ActivateKeyboardLayout(enHKL, 0); + } + + /// + /// Restores the previously backed-up keyboard layout. + /// If it wasn't backed up or has already been restored, this method does nothing. + /// + public static void RestorePreviousKeyboardLayout() + { + if (_previousLayout == HKL.Null) return; + + var hwnd = PInvoke.GetForegroundWindow(); + if (hwnd == HWND.Null) return; + + PInvoke.PostMessage( + hwnd, + PInvoke.WM_INPUTLANGCHANGEREQUEST, + PInvoke.INPUTLANGCHANGE_FORWARD, + _previousLayout.Value + ); + + _previousLayout = HKL.Null; + } + + /// + /// Finds an installed English keyboard layout. + /// + /// + /// + private static unsafe HKL FindEnglishKeyboardLayout() + { + // Get the number of keyboard layouts + int count = PInvoke.GetKeyboardLayoutList(0, null); + if (count <= 0) return HKL.Null; + + // Get all keyboard layouts + var handles = new HKL[count]; + fixed (HKL* h = handles) + { + var result = PInvoke.GetKeyboardLayoutList(count, h); + if (result == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Look for any English keyboard layout + foreach (var hkl in handles) + { + // The lower word contains the language identifier + var langId = (uint)hkl.Value & KeyboardLayoutLoWord; + var langTag = GetLanguageTag(langId); + + // Check if it's an English layout + if (langTag.StartsWith(EnglishLanguageTag, StringComparison.OrdinalIgnoreCase)) + { + return hkl; + } + } + + return HKL.Null; + } + + /// + /// Returns the + /// + /// BCP 47 language tag + /// + /// of the current input language. + /// + /// + /// Edited from: https://github.com/dotnet/winforms + /// + private static string GetLanguageTag(uint langId) + { + // We need to convert the language identifier to a language tag, because they are deprecated and may have a + // transient value. + // https://learn.microsoft.com/globalization/locale/other-locale-names#lcid + // https://learn.microsoft.com/windows/win32/winmsg/wm-inputlangchange#remarks + // + // It turns out that the LCIDToLocaleName API, which is used inside CultureInfo, may return incorrect + // language tags for transient language identifiers. For example, it returns "nqo-GN" and "jv-Java-ID" + // instead of the "nqo" and "jv-Java" (as seen in the Get-WinUserLanguageList PowerShell cmdlet). + // + // Try to extract proper language tag from registry as a workaround approved by a Windows team. + // https://github.com/dotnet/winforms/pull/8573#issuecomment-1542600949 + // + // NOTE: this logic may break in future versions of Windows since it is not documented. + if (langId is PInvoke.LOCALE_TRANSIENT_KEYBOARD1 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD2 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD3 + or PInvoke.LOCALE_TRANSIENT_KEYBOARD4) + { + using var key = Registry.CurrentUser.OpenSubKey(UserProfileRegistryPath); + if (key?.GetValue("Languages") is string[] languages) + { + foreach (string language in languages) + { + using var subKey = key.OpenSubKey(language); + if (subKey?.GetValue("TransientLangId") is int transientLangId + && transientLangId == langId) + { + return language; + } + } + } + } + + return CultureInfo.GetCultureInfo((int)langId).Name; + } + + #endregion + + #region Notification + + public static bool IsNotificationSupported() + { + // Notifications only supported on Windows 10 19041+ + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 19041; + } + + #endregion + + #region Korean IME + + public static bool IsWindows11() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 22000; + } + + public static bool IsKoreanIMEExist() + { + return GetLegacyKoreanIMERegistryValue() != null; + } + + public static bool IsLegacyKoreanIMEEnabled() + { + object value = GetLegacyKoreanIMERegistryValue(); + + if (value is int intValue) + { + return intValue == 1; + } + else if (value != null && int.TryParse(value.ToString(), out int parsedValue)) + { + return parsedValue == 1; + } + return false; } - /// - /// Sets the blur for a window via SetWindowCompositionAttribute - /// - public static void SetBlurForWindow(Window w, bool blur) + public static bool SetLegacyKoreanIMEEnabled(bool enable) { - SetWindowAccent(w, blur ? AccentState.ACCENT_ENABLE_BLURBEHIND : AccentState.ACCENT_DISABLED); - } + const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}"; + const string valueName = "NoTsf3Override5"; - private static void SetWindowAccent(Window w, AccentState state) - { - var windowHelper = new WindowInteropHelper(w); - - windowHelper.EnsureHandle(); - - var accent = new AccentPolicy { AccentState = state }; - var accentStructSize = Marshal.SizeOf(accent); - - var accentPtr = Marshal.AllocHGlobal(accentStructSize); - Marshal.StructureToPtr(accent, accentPtr, false); - - var data = new WindowCompositionAttributeData + try { - Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY, - SizeOfData = accentStructSize, - Data = accentPtr - }; + using RegistryKey key = Registry.CurrentUser.CreateSubKey(subKeyPath); + if (key != null) + { + int value = enable ? 1 : 0; + key.SetValue(valueName, value, RegistryValueKind.DWord); + return true; + } + } + catch (System.Exception) + { + // Ignored + } - SetWindowCompositionAttribute(windowHelper.Handle, ref data); - - Marshal.FreeHGlobal(accentPtr); + return false; } + + public static object GetLegacyKoreanIMERegistryValue() + { + const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}"; + const string valueName = "NoTsf3Override5"; + + try + { + using RegistryKey key = Registry.CurrentUser.OpenSubKey(subKeyPath); + if (key != null) + { + return key.GetValue(valueName); + } + } + catch (System.Exception) + { + // Ignored + } + + return null; + } + + public static void OpenImeSettings() + { + try + { + Process.Start(new ProcessStartInfo("ms-settings:regionlanguage") { UseShellExecute = true }); + } + catch (System.Exception) + { + // Ignored + } + } + + #endregion + + #region System Font + + private static readonly Dictionary _languageToNotoSans = new() + { + { "ko", "Noto Sans KR" }, + { "ja", "Noto Sans JP" }, + { "zh-CN", "Noto Sans SC" }, + { "zh-SG", "Noto Sans SC" }, + { "zh-Hans", "Noto Sans SC" }, + { "zh-TW", "Noto Sans TC" }, + { "zh-HK", "Noto Sans TC" }, + { "zh-MO", "Noto Sans TC" }, + { "zh-Hant", "Noto Sans TC" }, + { "th", "Noto Sans Thai" }, + { "ar", "Noto Sans Arabic" }, + { "he", "Noto Sans Hebrew" }, + { "hi", "Noto Sans Devanagari" }, + { "bn", "Noto Sans Bengali" }, + { "ta", "Noto Sans Tamil" }, + { "el", "Noto Sans Greek" }, + { "ru", "Noto Sans" }, + { "en", "Noto Sans" }, + { "fr", "Noto Sans" }, + { "de", "Noto Sans" }, + { "es", "Noto Sans" }, + { "pt", "Noto Sans" } + }; + + /// + /// Gets the system default font. + /// + /// + /// If true, it will try to find the Noto font for the current culture. + /// + /// + /// The name of the system default font. + /// + public static string GetSystemDefaultFont(bool useNoto = true) + { + try + { + if (useNoto) + { + var culture = CultureInfo.CurrentCulture; + var language = culture.Name; // e.g., "zh-TW" + var langPrefix = language.Split('-')[0]; // e.g., "zh" + + // First, try to find by full name, and if not found, fallback to prefix + if (TryGetNotoFont(language, out var notoFont) || TryGetNotoFont(langPrefix, out notoFont)) + { + // If the font is installed, return it + if (Fonts.SystemFontFamilies.Any(f => f.Source.Equals(notoFont))) + { + return notoFont; + } + } + } + + // If Noto font is not found, fallback to the system default font + var font = SystemFonts.MessageFontFamily; + if (font.FamilyNames.TryGetValue(XmlLanguage.GetLanguage("en-US"), out var englishName)) + { + return englishName; + } + + return font.Source ?? "Segoe UI"; + } + catch + { + return "Segoe UI"; + } + } + + private static bool TryGetNotoFont(string langKey, out string notoFont) + { + return _languageToNotoSans.TryGetValue(langKey, out notoFont); + } + + #endregion + + #region Explorer + + // https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems + + public static unsafe void OpenFolderAndSelectFile(string filePath) + { + ITEMIDLIST* pidlFolder = null; + ITEMIDLIST* pidlFile = null; + + var folderPath = Path.GetDirectoryName(filePath); + + try + { + var hrFolder = PInvoke.SHParseDisplayName(folderPath, null, out pidlFolder, 0, null); + if (hrFolder.Failed) throw new COMException("Failed to parse folder path", hrFolder); + + var hrFile = PInvoke.SHParseDisplayName(filePath, null, out pidlFile, 0, null); + if (hrFile.Failed) throw new COMException("Failed to parse file path", hrFile); + + var hrSelect = PInvoke.SHOpenFolderAndSelectItems(pidlFolder, 1, &pidlFile, 0); + if (hrSelect.Failed) throw new COMException("Failed to open folder and select item", hrSelect); + } + finally + { + if (pidlFile != null) PInvoke.CoTaskMemFree(pidlFile); + if (pidlFolder != null) PInvoke.CoTaskMemFree(pidlFolder); + } + } + #endregion } } diff --git a/Flow.Launcher.Plugin/ActionContext.cs b/Flow.Launcher.Plugin/ActionContext.cs index e31c8e31d..9e05bbd06 100644 --- a/Flow.Launcher.Plugin/ActionContext.cs +++ b/Flow.Launcher.Plugin/ActionContext.cs @@ -51,6 +51,9 @@ namespace Flow.Launcher.Plugin (WinPressed ? ModifierKeys.Windows : ModifierKeys.None); } + /// + /// Default object with all keys not pressed. + /// public static readonly SpecialKeyState Default = new () { CtrlPressed = false, ShiftPressed = false, diff --git a/Flow.Launcher.Plugin/AllowedLanguage.cs b/Flow.Launcher.Plugin/AllowedLanguage.cs index 619a94deb..0d22756a7 100644 --- a/Flow.Launcher.Plugin/AllowedLanguage.cs +++ b/Flow.Launcher.Plugin/AllowedLanguage.cs @@ -65,7 +65,42 @@ namespace Flow.Launcher.Plugin public static bool IsDotNet(string language) { return language.Equals(CSharp, StringComparison.OrdinalIgnoreCase) - || language.Equals(FSharp, StringComparison.OrdinalIgnoreCase); + || language.Equals(FSharp, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a Python language + /// + /// + /// + public static bool IsPython(string language) + { + return language.Equals(Python, StringComparison.OrdinalIgnoreCase) + || language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a Node.js language + /// + /// + /// + public static bool IsNodeJs(string language) + { + return language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase) + || language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase) + || language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase) + || language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines if this language is a executable language + /// + /// + /// + public static bool IsExecutable(string language) + { + return language.Equals(Executable, StringComparison.OrdinalIgnoreCase) + || language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase); } /// @@ -76,15 +111,9 @@ namespace Flow.Launcher.Plugin public static bool IsAllowed(string language) { return IsDotNet(language) - || language.Equals(Python, StringComparison.OrdinalIgnoreCase) - || language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(Executable, StringComparison.OrdinalIgnoreCase) - || language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase) - || language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase) - || language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase) - || language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase); - ; + || IsPython(language) + || IsNodeJs(language) + || IsExecutable(language); } } } diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index 2feb21b12..4a26cec95 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -14,10 +14,10 @@ - 4.4.0 - 4.4.0 - 4.4.0 - 4.4.0 + 4.5.0 + 4.5.0 + 4.5.0 + 4.5.0 Flow.Launcher.Plugin Flow-Launcher MIT @@ -66,7 +66,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -76,7 +76,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs new file mode 100644 index 000000000..78d6454ae --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncHomeQuery.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Query Model for Flow Launcher When Query Text is Empty + /// + public interface IAsyncHomeQuery : IFeatures + { + /// + /// Asynchronous Querying When Query Text is Empty + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncHomeQuery interface + /// + /// Cancel when querying job is obsolete + /// + Task> HomeQueryAsync(CancellationToken token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs new file mode 100644 index 000000000..81186fca2 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IHomeQuery.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Query Model for Flow Launcher When Query Text is Empty + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncHomeQuery interface + /// + /// + public interface IHomeQuery : IAsyncHomeQuery + { + /// + /// Querying When Query Text is Empty + /// + /// This method will be called within a Task.Run, + /// so please avoid synchronously wait for long. + /// + /// + /// + List HomeQuery(); + + Task> IAsyncHomeQuery.HomeQueryAsync(CancellationToken token) => Task.Run(HomeQuery); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 4c7af4cd4..cb60251ed 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -1,6 +1,4 @@ -using Flow.Launcher.Plugin.SharedModels; -using JetBrains.Annotations; -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; @@ -8,6 +6,9 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using System.Windows; +using System.Windows.Media; +using Flow.Launcher.Plugin.SharedModels; +using JetBrains.Annotations; namespace Flow.Launcher.Plugin { @@ -87,6 +88,11 @@ namespace Flow.Launcher.Plugin /// Show the MainWindow when hiding /// void ShowMainWindow(); + + /// + /// Focus the query text box in the main window + /// + void FocusQueryTextBox(); /// /// Hide MainWindow @@ -141,18 +147,49 @@ namespace Flow.Launcher.Plugin List GetAllPlugins(); /// - /// Register a callback for Global Keyboard Event + /// Registers a callback function for global keyboard events. /// - /// + /// + /// The callback function to invoke when a global keyboard event occurs. + /// + /// Parameters: + /// + /// int: The type of (key down, key up, etc.) + /// int: The virtual key code of the pressed/released key + /// : The state of modifier keys (Ctrl, Alt, Shift, etc.) + /// + /// + /// + /// Returns: true to allow normal system processing of the key event, + /// or false to intercept and prevent default handling. + /// + /// + /// + /// This callback will be invoked for all keyboard events system-wide. + /// Use with caution as intercepting system keys may affect normal system operation. + /// public void RegisterGlobalKeyboardCallback(Func callback); - + /// /// Remove a callback for Global Keyboard Event /// - /// + /// + /// The callback function to invoke when a global keyboard event occurs. + /// + /// Parameters: + /// + /// int: The type of (key down, key up, etc.) + /// int: The virtual key code of the pressed/released key + /// : The state of modifier keys (Ctrl, Alt, Shift, etc.) + /// + /// + /// + /// Returns: true to allow normal system processing of the key event, + /// or false to intercept and prevent default handling. + /// + /// public void RemoveGlobalKeyboardCallback(Func callback); - /// /// Fuzzy Search the string with the given query. This is the core search mechanism Flow uses /// @@ -191,14 +228,19 @@ namespace Flow.Launcher.Plugin Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default); /// - /// Add ActionKeyword for specific plugin + /// Add ActionKeyword and update action keyword metadata for specific plugin. + /// Before adding, please check if action keyword is already assigned by /// /// ID for plugin that needs to add action keyword /// The actionkeyword that is supposed to be added + /// + /// If new action keyword contains any whitespace, FL will still add it but it will not work for users. + /// So plugin should check the whitespace before calling this function. + /// void AddActionKeyword(string pluginId, string newActionKeyword); /// - /// Remove ActionKeyword for specific plugin + /// Remove ActionKeyword and update action keyword metadata for specific plugin /// /// ID for plugin that needs to remove action keyword /// The actionkeyword that is supposed to be removed @@ -227,6 +269,11 @@ namespace Flow.Launcher.Plugin /// void LogWarn(string className, string message, [CallerMemberName] string methodName = ""); + /// + /// Log error message. Preferred error logging method for plugins. + /// + void LogError(string className, string message, [CallerMemberName] string methodName = ""); + /// /// Log an Exception. Will throw if in debug mode so developer will be aware, /// otherwise logs the eror message. This is the primary logging method used for Flow @@ -242,9 +289,10 @@ namespace Flow.Launcher.Plugin T LoadSettingJsonStorage() where T : new(); /// - /// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow.Launcher + /// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow. /// This method will save the original instance loaded with LoadJsonStorage. - /// This API call is for manually Save. Flow will automatically save all setting type that has called LoadSettingJsonStorage or SaveSettingJsonStorage previously. + /// This API call is for manually Save. + /// Flow will automatically save all setting type that has called or previously. /// /// Type for Serialization /// @@ -344,5 +392,163 @@ namespace Flow.Launcher.Plugin /// Stop the loading bar in main window /// public void StopLoadingBar(); + + /// + /// Get all available themes + /// + /// + public List GetAvailableThemes(); + + /// + /// Get the current theme + /// + /// + public ThemeData GetCurrentTheme(); + + /// + /// Set the current theme + /// + /// + /// + /// True if the theme is set successfully, false otherwise. + /// + public bool SetCurrentTheme(ThemeData theme); + + /// + /// Save all Flow's plugins caches + /// + void SavePluginCaches(); + + /// + /// Load BinaryStorage for current plugin's cache. This is the method used to load cache from binary in Flow. + /// When the file is not exist, it will create a new instance for the specific type. + /// + /// Type for deserialization + /// Cache file name + /// Cache directory from plugin metadata + /// Default data to return + /// + /// + /// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable + /// + Task LoadCacheBinaryStorageAsync(string cacheName, string cacheDirectory, T defaultData) where T : new(); + + /// + /// Save BinaryStorage for current plugin's cache. This is the method used to save cache to binary in Flow. + /// This method will save the original instance loaded with LoadCacheBinaryStorageAsync. + /// This API call is for manually Save. + /// Flow will automatically save all cache type that has called or previously. + /// + /// Type for Serialization + /// Cache file name + /// Cache directory from plugin metadata + /// + /// + /// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable + /// + Task SaveCacheBinaryStorageAsync(string cacheName, string cacheDirectory) where T : new(); + + /// + /// Load image from path. + /// Support local, remote and data:image url. + /// Support png, jpg, jpeg, gif, bmp, tiff, ico, svg image files. + /// If image path is missing, it will return a missing icon. + /// + /// The path of the image. + /// + /// Load full image or not. + /// + /// + /// Cache the image or not. Cached image will be stored in FL cache. + /// If the image is just used one time, it's better to set this to false. + /// + /// + ValueTask LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true); + + /// + /// Update the plugin manifest + /// + /// + /// FL has multiple urls to download the plugin manifest. Set this to true to only use the primary url. + /// + /// + /// True if the manifest is updated successfully, false otherwise + public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default); + + /// + /// Get the plugin manifest. + /// + /// + /// If Flow cannot get manifest data, this could be null + /// + /// + public IReadOnlyList GetPluginManifest(); + + /// + /// Check if the plugin has been modified. + /// If this plugin is updated, installed or uninstalled and users do not restart the app, + /// it will be marked as modified + /// + /// Plugin id + /// + public bool PluginModified(string id); + + /// + /// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url, + /// unless it's a local path installation + /// + /// The metadata of the old plugin to update + /// The new plugin to update + /// + /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. + /// + /// + public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); + + /// + /// Install a plugin. By default will remove the zip file if installation is from url, + /// unless it's a local path installation + /// + /// The plugin to install + /// + /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. + /// + public void InstallPlugin(UserPlugin plugin, string zipFilePath); + + /// + /// Uninstall a plugin + /// + /// The metadata of the plugin to uninstall + /// + /// Plugin has their own settings. If this is set to true, the plugin settings will be removed. + /// + /// + public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); + + /// + /// Log debug message of the time taken to execute a method + /// Message will only be logged in Debug mode + /// + /// The time taken to execute the method in milliseconds + public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = ""); + + /// + /// Log debug message of the time taken to execute a method asynchronously + /// Message will only be logged in Debug mode + /// + /// The time taken to execute the method in milliseconds + public Task StopwatchLogDebugAsync(string className, string message, Func action, [CallerMemberName] string methodName = ""); + + /// + /// Log info message of the time taken to execute a method + /// + /// The time taken to execute the method in milliseconds + public long StopwatchLogInfo(string className, string message, Action action, [CallerMemberName] string methodName = ""); + + /// + /// Log info message of the time taken to execute a method asynchronously + /// + /// The time taken to execute the method in milliseconds + public Task StopwatchLogInfoAsync(string className, string message, Func action, [CallerMemberName] string methodName = ""); } } diff --git a/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs index fd21460ac..aa4e4a56d 100644 --- a/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs +++ b/Flow.Launcher.Plugin/Interfaces/IResultUpdated.cs @@ -4,17 +4,42 @@ using System.Threading; namespace Flow.Launcher.Plugin { + /// + /// Interface for plugins that want to manually update their results + /// public interface IResultUpdated : IFeatures { + /// + /// Event that is triggered when the results are updated + /// event ResultUpdatedEventHandler ResultsUpdated; } + /// + /// Delegate for the ResultsUpdated event + /// + /// + /// public delegate void ResultUpdatedEventHandler(IResultUpdated sender, ResultUpdatedEventArgs e); + /// + /// Event arguments for the ResultsUpdated event + /// public class ResultUpdatedEventArgs : EventArgs { + /// + /// List of results that should be displayed + /// public List Results; + + /// + /// Query that triggered the update + /// public Query Query; + + /// + /// Token that can be used to cancel the update + /// public CancellationToken Token { get; init; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/Interfaces/ISavable.cs b/Flow.Launcher.Plugin/Interfaces/ISavable.cs index 77bd304e4..38cbf8e08 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISavable.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISavable.cs @@ -1,18 +1,21 @@ -namespace Flow.Launcher.Plugin +namespace Flow.Launcher.Plugin { /// - /// Inherit this interface if additional data e.g. cache needs to be saved. + /// Inherit this interface if you need to save additional data which is not a setting or cache, + /// please implement this interface. /// /// /// For storing plugin settings, prefer - /// or . - /// Once called, your settings will be automatically saved by Flow. + /// or . + /// For storing plugin caches, prefer + /// or . + /// Once called, those settings and caches will be automatically saved by Flow. /// public interface ISavable : IFeatures { /// - /// Save additional plugin data, such as cache. + /// Save additional plugin data. /// void Save(); } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs index d5ffba20b..f034243c3 100644 --- a/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs +++ b/Flow.Launcher.Plugin/Interfaces/ISettingProvider.cs @@ -2,8 +2,15 @@ namespace Flow.Launcher.Plugin { + /// + /// This interface is used to create settings panel for .Net plugins + /// public interface ISettingProvider { + /// + /// Create settings panel control for .Net plugins + /// + /// Control CreateSettingPanel(); } } diff --git a/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs b/Flow.Launcher.Plugin/KeyEvent.cs similarity index 61% rename from Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs rename to Flow.Launcher.Plugin/KeyEvent.cs index 95bb25837..321f17cc1 100644 --- a/Flow.Launcher.Infrastructure/Hotkey/KeyEvent.cs +++ b/Flow.Launcher.Plugin/KeyEvent.cs @@ -1,7 +1,12 @@ using Windows.Win32; -namespace Flow.Launcher.Infrastructure.Hotkey +namespace Flow.Launcher.Plugin { + /// + /// Enumeration of key events for + /// + /// and + /// public enum KeyEvent { /// diff --git a/Flow.Launcher.Plugin/NativeMethods.txt b/Flow.Launcher.Plugin/NativeMethods.txt index e3e2b705e..0596691cc 100644 --- a/Flow.Launcher.Plugin/NativeMethods.txt +++ b/Flow.Launcher.Plugin/NativeMethods.txt @@ -1,3 +1,8 @@ EnumThreadWindows GetWindowText -GetWindowTextLength \ No newline at end of file +GetWindowTextLength + +WM_KEYDOWN +WM_KEYUP +WM_SYSKEYDOWN +WM_SYSKEYUP \ No newline at end of file diff --git a/Flow.Launcher.Plugin/PluginInitContext.cs b/Flow.Launcher.Plugin/PluginInitContext.cs index f040752bd..a42e3930c 100644 --- a/Flow.Launcher.Plugin/PluginInitContext.cs +++ b/Flow.Launcher.Plugin/PluginInitContext.cs @@ -5,10 +5,18 @@ /// public class PluginInitContext { + /// + /// Default constructor. + /// public PluginInitContext() { } + /// + /// Constructor. + /// + /// + /// public PluginInitContext(PluginMetadata currentPluginMetadata, IPublicAPI api) { CurrentPluginMetadata = currentPluginMetadata; diff --git a/Flow.Launcher.Plugin/PluginMetadata.cs b/Flow.Launcher.Plugin/PluginMetadata.cs index b4e06913e..09803cbd7 100644 --- a/Flow.Launcher.Plugin/PluginMetadata.cs +++ b/Flow.Launcher.Plugin/PluginMetadata.cs @@ -4,24 +4,82 @@ using System.Text.Json.Serialization; namespace Flow.Launcher.Plugin { + /// + /// Plugin metadata + /// public class PluginMetadata : BaseModel { - private string _pluginDirectory; + /// + /// Plugin ID. + /// public string ID { get; set; } - public string Name { get; set; } - public string Author { get; set; } - public string Version { get; set; } - public string Language { get; set; } - public string Description { get; set; } - public string Website { get; set; } - public bool Disabled { get; set; } - public string ExecuteFilePath { get; private set;} + /// + /// Plugin name. + /// + public string Name { get; set; } + + /// + /// Plugin author. + /// + public string Author { get; set; } + + /// + /// Plugin version. + /// + public string Version { get; set; } + + /// + /// Plugin language. + /// See + /// + public string Language { get; set; } + + /// + /// Plugin description. + /// + public string Description { get; set; } + + /// + /// Plugin website. + /// + public string Website { get; set; } + + /// + /// Whether plugin is disabled. + /// + public bool Disabled { get; set; } + + /// + /// Whether plugin is disabled in home query. + /// + public bool HomeDisabled { get; set; } + + /// + /// Plugin execute file path. + /// + public string ExecuteFilePath { get; private set; } + + /// + /// Plugin execute file name. + /// public string ExecuteFileName { get; set; } + /// + /// Plugin assembly name. + /// Only available for .Net plugins. + /// + [JsonIgnore] + public string AssemblyName { get; internal set; } + + private string _pluginDirectory; + + /// + /// Plugin source directory. + /// public string PluginDirectory { - get { return _pluginDirectory; } + get => _pluginDirectory; internal set { _pluginDirectory = value; @@ -30,28 +88,77 @@ namespace Flow.Launcher.Plugin } } + /// + /// The first action keyword of plugin. + /// public string ActionKeyword { get; set; } + /// + /// All action keywords of plugin. + /// public List ActionKeywords { get; set; } - public string IcoPath { get; set;} - - public override string ToString() - { - return Name; - } + /// + /// Hide plugin keyword setting panel. + /// + public bool HideActionKeywordPanel { get; set; } + /// + /// Plugin search delay time in ms. Null means use default search delay time. + /// + public int? SearchDelayTime { get; set; } = null; + + /// + /// Plugin icon path. + /// + public string IcoPath { get; set;} + + /// + /// Plugin priority. + /// [JsonIgnore] public int Priority { get; set; } /// - /// Init time include both plugin load time and init time + /// Init time include both plugin load time and init time. /// [JsonIgnore] public long InitTime { get; set; } + + /// + /// Average query time. + /// [JsonIgnore] public long AvgQueryTime { get; set; } + + /// + /// Query count. + /// [JsonIgnore] public int QueryCount { get; set; } + + /// + /// The path to the plugin settings directory which is not validated. + /// It is used to store plugin settings files and data files. + /// When plugin is deleted, FL will ask users whether to keep its settings. + /// If users do not want to keep, this directory will be deleted. + /// + public string PluginSettingsDirectoryPath { get; internal set; } + + /// + /// The path to the plugin cache directory which is not validated. + /// It is used to store cache files. + /// When plugin is deleted, this directory will be deleted as well. + /// + public string PluginCacheDirectoryPath { get; internal set; } + + /// + /// Convert to string. + /// + /// + public override string ToString() + { + return Name; + } } } diff --git a/Flow.Launcher.Plugin/PluginPair.cs b/Flow.Launcher.Plugin/PluginPair.cs index 7bf634691..f2c14d70c 100644 --- a/Flow.Launcher.Plugin/PluginPair.cs +++ b/Flow.Launcher.Plugin/PluginPair.cs @@ -1,21 +1,37 @@ namespace Flow.Launcher.Plugin { + /// + /// Plugin instance and plugin metadata + /// public class PluginPair { + /// + /// Plugin instance + /// public IAsyncPlugin Plugin { get; internal set; } + + /// + /// Plugin metadata + /// public PluginMetadata Metadata { get; internal set; } - - + /// + /// Convert to string + /// + /// public override string ToString() { return Metadata.Name; } + /// + /// Compare by plugin metadata ID + /// + /// + /// public override bool Equals(object obj) { - PluginPair r = obj as PluginPair; - if (r != null) + if (obj is PluginPair r) { return string.Equals(r.Metadata.ID, Metadata.ID); } @@ -25,6 +41,10 @@ } } + /// + /// Get hash code + /// + /// public override int GetHashCode() { var hashcode = Metadata.ID?.GetHashCode() ?? 0; diff --git a/Flow.Launcher.Plugin/Query.cs b/Flow.Launcher.Plugin/Query.cs index e182491c2..f50614699 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -2,12 +2,14 @@ namespace Flow.Launcher.Plugin { + /// + /// Represents a query that is sent to a plugin. + /// public class Query { - public Query() { } - /// - /// Raw query, this includes action keyword if it has + /// Raw query, this includes action keyword if it has. + /// It has handled buildin custom query shortkeys and build-in shortcuts, and it trims the whitespace. /// We didn't recommend use this property directly. You should always use Search property. /// public string RawQuery { get; internal init; } @@ -19,6 +21,11 @@ namespace Flow.Launcher.Plugin /// public bool IsReQuery { get; internal set; } = false; + /// + /// Determines whether the query is a home query. + /// + public bool IsHomeQuery { get; internal init; } = false; + /// /// Search part of a query. /// This will not include action keyword if exclusive plugin gets it, otherwise it should be same as RawQuery. @@ -39,10 +46,9 @@ namespace Flow.Launcher.Plugin public const string TermSeparator = " "; /// - /// User can set multiple action keywords seperated by ';' + /// User can set multiple action keywords seperated by whitespace /// - public const string ActionKeywordSeparator = ";"; - + public const string ActionKeywordSeparator = TermSeparator; /// /// Wildcard action keyword. Plugins using this value will be queried on every search. @@ -55,18 +61,18 @@ namespace Flow.Launcher.Plugin /// public string ActionKeyword { get; init; } - [JsonIgnore] /// /// Splits by spaces and returns the first item. /// /// /// returns an empty string when does not have enough items. /// + [JsonIgnore] public string FirstSearch => SplitSearch(0); - + [JsonIgnore] private string _secondToEndSearch; - + /// /// strings from second search (including) to last search /// diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index 9b16cc1cb..f0fcd48ff 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -13,6 +12,10 @@ namespace Flow.Launcher.Plugin /// public class Result { + /// + /// Maximum score. This can be useful when set one result to the top by default. This is the score for the results set to the topmost by users. + /// + public const int MaxScore = int.MaxValue; private string _pluginDirectory; @@ -20,6 +23,8 @@ namespace Flow.Launcher.Plugin private string _copyText = string.Empty; + private string _badgeIcoPath; + /// /// The title of the result. This is always required. /// @@ -62,7 +67,7 @@ namespace Flow.Launcher.Plugin /// GlyphInfo is prioritized if not null public string IcoPath { - get { return _icoPath; } + get => _icoPath; set { // As a standard this property will handle prepping and converting to absolute local path for icon image processing @@ -82,6 +87,33 @@ namespace Flow.Launcher.Plugin } } + /// + /// The image to be displayed for the badge of the result. + /// + /// Can be a local file path or a URL. + /// If null or empty, will use plugin icon + public string BadgeIcoPath + { + get => _badgeIcoPath; + set + { + // As a standard this property will handle prepping and converting to absolute local path for icon image processing + if (!string.IsNullOrEmpty(value) + && !string.IsNullOrEmpty(PluginDirectory) + && !Path.IsPathRooted(value) + && !value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + && !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) + { + _badgeIcoPath = Path.Combine(PluginDirectory, value); + } + else + { + _badgeIcoPath = value; + } + } + } + /// /// Determines if Icon has a border radius /// @@ -96,14 +128,18 @@ namespace Flow.Launcher.Plugin /// /// Delegate to load an icon for this result. /// - public IconDelegate Icon; + public IconDelegate Icon = null; + + /// + /// Delegate to load an icon for the badge of this result. + /// + public IconDelegate BadgeIcon = null; /// /// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons) /// public GlyphInfo Glyph { get; init; } - /// /// An action to take in the form of a function call when the result has been selected. /// @@ -145,59 +181,19 @@ namespace Flow.Launcher.Plugin /// public string PluginDirectory { - get { return _pluginDirectory; } + get => _pluginDirectory; set { _pluginDirectory = value; // When the Result object is returned from the query call, PluginDirectory is not provided until // UpdatePluginMetadata call is made at PluginManager.cs L196. Once the PluginDirectory becomes available - // we need to update (only if not Uri path) the IcoPath with the full absolute path so the image can be loaded. + // we need to update (only if not Uri path) the IcoPath and BadgeIcoPath with the full absolute path so the image can be loaded. IcoPath = _icoPath; + BadgeIcoPath = _badgeIcoPath; } } - /// - public override string ToString() - { - return Title + SubTitle + Score; - } - - /// - /// Clones the current result - /// - public Result Clone() - { - return new Result - { - Title = Title, - SubTitle = SubTitle, - ActionKeywordAssigned = ActionKeywordAssigned, - CopyText = CopyText, - AutoCompleteText = AutoCompleteText, - IcoPath = IcoPath, - RoundedIcon = RoundedIcon, - Icon = Icon, - Glyph = Glyph, - Action = Action, - AsyncAction = AsyncAction, - Score = Score, - TitleHighlightData = TitleHighlightData, - OriginQuery = OriginQuery, - PluginDirectory = PluginDirectory, - ContextData = ContextData, - PluginID = PluginID, - TitleToolTip = TitleToolTip, - SubTitleToolTip = SubTitleToolTip, - PreviewPanel = PreviewPanel, - ProgressBar = ProgressBar, - ProgressBarColor = ProgressBarColor, - Preview = Preview, - AddSelectedCount = AddSelectedCount, - RecordKey = RecordKey - }; - } - /// /// Additional data associated with this result /// @@ -226,16 +222,6 @@ namespace Flow.Launcher.Plugin /// public Lazy PreviewPanel { get; set; } - /// - /// Run this result, asynchronously - /// - /// - /// - public ValueTask ExecuteAsync(ActionContext context) - { - return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false); - } - /// /// Progress bar display. Providing an int value between 0-100 will trigger the progress bar to be displayed on the result /// @@ -257,11 +243,6 @@ namespace Flow.Launcher.Plugin /// public bool AddSelectedCount { get; set; } = true; - /// - /// Maximum score. This can be useful when set one result to the top by default. This is the score for the results set to the topmost by users. - /// - public const int MaxScore = int.MaxValue; - /// /// The key to identify the record. This is used when FL checks whether the result is the topmost record. Or FL calculates the hashcode of the result for user selected records. /// This can be useful when your plugin will change the Title or SubTitle of the result dynamically. @@ -270,6 +251,66 @@ namespace Flow.Launcher.Plugin /// public string RecordKey { get; set; } = null; + /// + /// Determines if the badge icon should be shown. + /// If users want to show the result badges and here you set this to true, the results will show the badge icon. + /// + public bool ShowBadge { get; set; } = false; + + /// + /// Run this result, asynchronously + /// + /// + /// + public ValueTask ExecuteAsync(ActionContext context) + { + return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false); + } + + /// + public override string ToString() + { + return Title + SubTitle + Score; + } + + /// + /// Clones the current result + /// + public Result Clone() + { + return new Result + { + Title = Title, + SubTitle = SubTitle, + ActionKeywordAssigned = ActionKeywordAssigned, + CopyText = CopyText, + AutoCompleteText = AutoCompleteText, + IcoPath = IcoPath, + BadgeIcoPath = BadgeIcoPath, + RoundedIcon = RoundedIcon, + Icon = Icon, + BadgeIcon = BadgeIcon, + Glyph = Glyph, + Action = Action, + AsyncAction = AsyncAction, + Score = Score, + TitleHighlightData = TitleHighlightData, + OriginQuery = OriginQuery, + PluginDirectory = PluginDirectory, + ContextData = ContextData, + PluginID = PluginID, + TitleToolTip = TitleToolTip, + SubTitleToolTip = SubTitleToolTip, + PreviewPanel = PreviewPanel, + ProgressBar = ProgressBar, + ProgressBarColor = ProgressBarColor, + Preview = Preview, + AddSelectedCount = AddSelectedCount, + RecordKey = RecordKey, + ShowBadge = ShowBadge, + }; + } + /// /// Info of the preview section of a /// diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index 5f003e351..6c506cfc0 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -264,12 +264,12 @@ namespace Flow.Launcher.Plugin.SharedCommands var index = path.LastIndexOf('\\'); if (index > 0 && index < (path.Length - 1)) { - string previousDirectoryPath = path.Substring(0, index + 1); - return locationExists(previousDirectoryPath) ? previousDirectoryPath : ""; + string previousDirectoryPath = path[..(index + 1)]; + return locationExists(previousDirectoryPath) ? previousDirectoryPath : string.Empty; } else { - return ""; + return string.Empty; } } @@ -285,7 +285,7 @@ namespace Flow.Launcher.Plugin.SharedCommands // not full path, get previous level directory string var indexOfSeparator = path.LastIndexOf('\\'); - return path.Substring(0, indexOfSeparator + 1); + return path[..(indexOfSeparator + 1)]; } return path; @@ -318,5 +318,51 @@ namespace Flow.Launcher.Plugin.SharedCommands { return path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; } + + /// + /// Validates a directory, creating it if it doesn't exist + /// + /// + public static void ValidateDirectory(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + /// + /// Validates a data directory, synchronizing it by ensuring all files from a bundled source directory exist in it. + /// If files are missing or outdated, they are copied from the bundled directory to the data directory. + /// + /// + /// + public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory) + { + if (!Directory.Exists(dataDirectory)) + { + Directory.CreateDirectory(dataDirectory); + } + + foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory)) + { + var data = Path.GetFileName(bundledDataPath); + if (data == null) continue; + var dataPath = Path.Combine(dataDirectory, data); + if (!File.Exists(dataPath)) + { + File.Copy(bundledDataPath, dataPath); + } + else + { + var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc; + var time2 = new FileInfo(dataPath).LastWriteTimeUtc; + if (time1 != time2) + { + File.Copy(bundledDataPath, dataPath, true); + } + } + } + } } } diff --git a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs index a7744ffac..ed3e91daf 100644 --- a/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs +++ b/Flow.Launcher.Plugin/SharedCommands/SearchWeb.cs @@ -1,16 +1,20 @@ -using Microsoft.Win32; -using System; +using System; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; +using Microsoft.Win32; namespace Flow.Launcher.Plugin.SharedCommands { + /// + /// Contains methods to open a search in a new browser window or tab. + /// public static class SearchWeb { private static string GetDefaultBrowserPath() { - string name = string.Empty; + var name = string.Empty; try { using var regDefault = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice", false); @@ -20,8 +24,7 @@ namespace Flow.Launcher.Plugin.SharedCommands name = regKey.GetValue(null).ToString().ToLower().Replace("\"", ""); if (!name.EndsWith("exe")) - name = name.Substring(0, name.LastIndexOf(".exe") + 4); - + name = name[..(name.LastIndexOf(".exe") + 4)]; } catch { @@ -62,12 +65,21 @@ namespace Flow.Launcher.Plugin.SharedCommands { Process.Start(psi)?.Dispose(); } - catch (System.ComponentModel.Win32Exception) + // This error may be thrown if browser path is incorrect + catch (Win32Exception) { - Process.Start(new ProcessStartInfo + try { - FileName = url, UseShellExecute = true - }); + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch + { + throw; // Re-throw the exception if we cannot open the URL in the default browser + } } } @@ -97,13 +109,21 @@ namespace Flow.Launcher.Plugin.SharedCommands Process.Start(psi)?.Dispose(); } // This error may be thrown if browser path is incorrect - catch (System.ComponentModel.Win32Exception) + catch (Win32Exception) { - Process.Start(new ProcessStartInfo + try { - FileName = url, UseShellExecute = true - }); + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch + { + throw; // Re-throw the exception if we cannot open the URL in the default browser + } } } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs index a0440e30d..288222d4f 100644 --- a/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs +++ b/Flow.Launcher.Plugin/SharedCommands/ShellCommand.cs @@ -8,12 +8,26 @@ using Windows.Win32.Foundation; namespace Flow.Launcher.Plugin.SharedCommands { + /// + /// Contains methods for running shell commands + /// public static class ShellCommand { + /// + /// Delegate for EnumThreadWindows + /// + /// + /// + /// public delegate bool EnumThreadDelegate(IntPtr hwnd, IntPtr lParam); private static bool containsSecurityWindow; + /// + /// Runs a windows command using the provided ProcessStartInfo + /// + /// + /// public static Process RunAsDifferentUser(ProcessStartInfo processStartInfo) { processStartInfo.Verb = "RunAsUser"; @@ -65,6 +79,15 @@ namespace Flow.Launcher.Plugin.SharedCommands return buffer[..length].ToString(); } + /// + /// Runs a windows command using the provided ProcessStartInfo + /// + /// + /// + /// + /// + /// + /// public static ProcessStartInfo SetProcessStartInfo(this string fileName, string workingDirectory = "", string arguments = "", string verb = "", bool createNoWindow = false) { diff --git a/Flow.Launcher.Plugin/SharedModels/MatchResult.cs b/Flow.Launcher.Plugin/SharedModels/MatchResult.cs index 5144eb61d..36677d4bb 100644 --- a/Flow.Launcher.Plugin/SharedModels/MatchResult.cs +++ b/Flow.Launcher.Plugin/SharedModels/MatchResult.cs @@ -2,14 +2,29 @@ namespace Flow.Launcher.Plugin.SharedModels { + /// + /// Represents the result of a match operation. + /// public class MatchResult { + /// + /// Initializes a new instance of the class. + /// + /// + /// public MatchResult(bool success, SearchPrecisionScore searchPrecision) { Success = success; SearchPrecision = searchPrecision; } + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// public MatchResult(bool success, SearchPrecisionScore searchPrecision, List matchData, int rawScore) { Success = success; @@ -18,6 +33,9 @@ namespace Flow.Launcher.Plugin.SharedModels RawScore = rawScore; } + /// + /// Whether the match operation was successful. + /// public bool Success { get; set; } /// @@ -30,6 +48,9 @@ namespace Flow.Launcher.Plugin.SharedModels /// private int _rawScore; + /// + /// The raw calculated search score without any search precision filtering applied. + /// public int RawScore { get { return _rawScore; } @@ -45,8 +66,15 @@ namespace Flow.Launcher.Plugin.SharedModels /// public List MatchData { get; set; } + /// + /// The search precision score used to filter the search results. + /// public SearchPrecisionScore SearchPrecision { get; set; } + /// + /// Determines if the search precision score is met. + /// + /// public bool IsSearchPrecisionScoreMet() { return IsSearchPrecisionScoreMet(_rawScore); @@ -63,10 +91,24 @@ namespace Flow.Launcher.Plugin.SharedModels } } + /// + /// Represents the search precision score used to filter search results. + /// public enum SearchPrecisionScore { + /// + /// The highest search precision score. + /// Regular = 50, + + /// + /// The medium search precision score. + /// Low = 20, + + /// + /// The lowest search precision score. + /// None = 0 } } diff --git a/Flow.Launcher.Plugin/SharedModels/ThemeData.cs b/Flow.Launcher.Plugin/SharedModels/ThemeData.cs new file mode 100644 index 000000000..cb389c21f --- /dev/null +++ b/Flow.Launcher.Plugin/SharedModels/ThemeData.cs @@ -0,0 +1,77 @@ +using System; + +namespace Flow.Launcher.Plugin.SharedModels; + +/// +/// Theme data model +/// +public class ThemeData +{ + /// + /// Theme file name without extension + /// + public string FileNameWithoutExtension { get; private init; } + + /// + /// Theme name + /// + public string Name { get; private init; } + + /// + /// Indicates whether the theme supports dark mode + /// + public bool? IsDark { get; private init; } + + /// + /// Indicates whether the theme supports blur effects + /// + public bool? HasBlur { get; private init; } + + /// + /// Theme data constructor + /// + public ThemeData(string fileNameWithoutExtension, string name, bool? isDark = null, bool? hasBlur = null) + { + FileNameWithoutExtension = fileNameWithoutExtension; + Name = name; + IsDark = isDark; + HasBlur = hasBlur; + } + + /// + public static bool operator ==(ThemeData left, ThemeData right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + /// + public static bool operator !=(ThemeData left, ThemeData right) + { + return !(left == right); + } + + /// + public override bool Equals(object obj) + { + if (obj is not ThemeData other) + return false; + return FileNameWithoutExtension == other.FileNameWithoutExtension && + Name == other.Name; + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(FileNameWithoutExtension, Name); + } + + /// + public override string ToString() + { + return Name; + } +} diff --git a/Flow.Launcher.Plugin/UserPlugin.cs b/Flow.Launcher.Plugin/UserPlugin.cs new file mode 100644 index 000000000..74a16b83d --- /dev/null +++ b/Flow.Launcher.Plugin/UserPlugin.cs @@ -0,0 +1,80 @@ +using System; + +namespace Flow.Launcher.Plugin +{ + /// + /// User Plugin Model for Flow Launcher + /// + public record UserPlugin + { + /// + /// Unique identifier of the plugin + /// + public string ID { get; set; } + + /// + /// Name of the plugin + /// + public string Name { get; set; } + + /// + /// Description of the plugin + /// + public string Description { get; set; } + + /// + /// Author of the plugin + /// + public string Author { get; set; } + + /// + /// Version of the plugin + /// + public string Version { get; set; } + + /// + /// Allow language of the plugin + /// + public string Language { get; set; } + + /// + /// Website of the plugin + /// + public string Website { get; set; } + + /// + /// URL to download the plugin + /// + public string UrlDownload { get; set; } + + /// + /// URL to the source code of the plugin + /// + public string UrlSourceCode { get; set; } + + /// + /// Local path where the plugin is installed + /// + public string LocalInstallPath { get; set; } + + /// + /// Icon path of the plugin + /// + public string IcoPath { get; set; } + + /// + /// The date when the plugin was last updated + /// + public DateTime? LatestReleaseDate { get; set; } + + /// + /// The date when the plugin was added to the local system + /// + public DateTime? DateAdded { get; set; } + + /// + /// Indicates whether the plugin is installed from a local path + /// + public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath); + } +} diff --git a/Flow.Launcher.Test/Plugins/ExplorerTest.cs b/Flow.Launcher.Test/Plugins/ExplorerTest.cs index 420da266d..9ec952155 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -39,8 +39,8 @@ namespace Flow.Launcher.Test.Plugins } [SupportedOSPlatform("windows7.0")] - [TestCase("C:\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY System.FileName")] - [TestCase("C:\\SomeFolder\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY System.FileName")] + [TestCase("C:\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("C:\\SomeFolder\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchTypeIsTopLevelDirectorySearch_ThenQueryShouldUseExpectedString(string folderPath, string expectedString) { // Given @@ -59,7 +59,7 @@ namespace Flow.Launcher.Test.Plugins [TestCase("C:\\SomeFolder", "flow.launcher.sln", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType" + " FROM SystemIndex WHERE directory='file:C:\\SomeFolder'" + " AND (System.FileName LIKE 'flow.launcher.sln%' OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"'))" + - " ORDER BY System.FileName")] + $" ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryShouldUseExpectedString( string folderPath, string userSearchString, string expectedString) { @@ -87,8 +87,8 @@ namespace Flow.Launcher.Test.Plugins [SupportedOSPlatform("windows7.0")] [TestCase("flow.launcher.sln", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" " + "FROM \"SystemIndex\" WHERE (System.FileName LIKE 'flow.launcher.sln%' " + - "OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY System.FileName")] - [TestCase("", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY System.FileName")] + $"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] + [TestCase("", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { @@ -107,7 +107,6 @@ namespace Flow.Launcher.Test.Plugins ClassicAssert.AreEqual(expectedString, resultString); } - [SupportedOSPlatform("windows7.0")] [TestCase(@"some words", @"FREETEXT('some words')")] public void GivenWindowsIndexSearch_WhenQueryWhereRestrictionsIsForFileContentSearch_ThenShouldReturnFreeTextString( @@ -127,7 +126,7 @@ namespace Flow.Launcher.Test.Plugins [SupportedOSPlatform("windows7.0")] [TestCase("some words", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType " + - "FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY System.FileName")] + $"FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")] public void GivenWindowsIndexSearch_WhenSearchForFileContent_ThenQueryShouldUseExpectedString( string userSearchString, string expectedString) { diff --git a/Flow.Launcher/ActionKeywords.xaml b/Flow.Launcher/ActionKeywords.xaml index 740b0d402..887b13126 100644 --- a/Flow.Launcher/ActionKeywords.xaml +++ b/Flow.Launcher/ActionKeywords.xaml @@ -53,11 +53,11 @@ - - + + - + - + @@ -112,20 +112,20 @@ Grid.Row="1" Background="{DynamicResource PopupButtonAreaBGColor}" BorderBrush="{DynamicResource PopupButtonAreaBorderColor}" - BorderThickness="0,1,0,0"> + BorderThickness="0 1 0 0"> - + - + @@ -99,12 +99,12 @@ Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="2" - Margin="10,0,10,0" + Margin="10 0 10 0" HorizontalAlignment="Left" VerticalAlignment="Center" HorizontalContentAlignment="Left" - HotkeySettings="{Binding Settings}" - DefaultHotkey="" /> + DefaultHotkey="" + Type="CustomQueryHotkey" /> @@ -133,21 +133,21 @@ + BorderThickness="0 1 0 0"> - - + + - + @@ -124,14 +124,14 @@ LastChildFill="True"> + IsEnabled="{Binding HomeEnabled}" + IsOn="{Binding PluginHomeState}" + OffContent="{DynamicResource disable}" + OnContent="{DynamicResource enable}" + ToolTip="{DynamicResource homeToggleBoxToolTip}" + Visibility="{Binding DataContext.IsHomeOnOffSelected, RelativeSource={RelativeSource AncestorType=ListBox}, Converter={StaticResource BooleanToVisibilityConverter}}" /> + OnContent="{DynamicResource enable}" + Visibility="{Binding DataContext.IsOnOffSelected, RelativeSource={RelativeSource AncestorType=ListBox}, Converter={StaticResource BooleanToVisibilityConverter}}" /> @@ -96,9 +129,9 @@ + BorderThickness="0 1 0 0"> + + + + + {DynamicResource SettingWindowFont} + + @@ -755,17 +773,33 @@ - + + + + + + + + @@ -773,7 +807,7 @@ - + @@ -792,7 +826,8 @@ - + + @@ -802,7 +837,8 @@ - + + @@ -822,7 +858,7 @@ - + @@ -1103,7 +1139,8 @@ IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" PlacementTarget="{Binding ElementName=Background}" - PopupAnimation="None"> + PopupAnimation="None" + VerticalOffset="-1"> @@ -1123,11 +1160,11 @@ + Background="{DynamicResource CustomPopUpBorderBG}" + CornerRadius="5"> @@ -1157,8 +1194,8 @@ - - + + @@ -1285,7 +1322,6 @@ BasedOn="{StaticResource DefaultComboBoxStyle}" TargetType="ComboBox"> - @@ -1503,7 +1539,7 @@ - + @@ -1550,6 +1586,7 @@ + + + + + + + 70,13.5,18,13.5 + + 9,0,0,0 + 0,0,9,0 + 0,4.5,0,4.5 + + 9,4.5,0,4.5 + 0,4.5,9,4.5 + + + + 180 + 240 + 150 + + diff --git a/Flow.Launcher/Resources/Dark.xaml b/Flow.Launcher/Resources/Dark.xaml index ed031c939..3fd66d623 100644 --- a/Flow.Launcher/Resources/Dark.xaml +++ b/Flow.Launcher/Resources/Dark.xaml @@ -7,19 +7,20 @@ xmlns:sys="clr-namespace:System;assembly=mscorlib"> - + - - - - - + + + + + - - - + + + #198F8F8F + @@ -58,6 +59,9 @@ + + + @@ -103,6 +107,7 @@ #f5f5f5 #464646 #ffffff + #272727 @@ -110,11 +115,22 @@ - - - - + + + + + + + + + + + + @@ -145,8 +161,14 @@ + + + + + + diff --git a/Flow.Launcher/Resources/Light.xaml b/Flow.Launcher/Resources/Light.xaml index 8fe84588f..112815ed0 100644 --- a/Flow.Launcher/Resources/Light.xaml +++ b/Flow.Launcher/Resources/Light.xaml @@ -7,20 +7,22 @@ xmlns:sys="clr-namespace:System;assembly=mscorlib"> - + - - - + + + - - - - #198F8F8F + + + + #7EFFFFFF - + + + @@ -51,6 +53,9 @@ + + + @@ -94,19 +99,33 @@ #f5f5f5 #878787 #1b1b1b + #f6f6f6 + + - + + + + + - + + + + + @@ -136,8 +155,17 @@ + + + + + + @@ -148,7 +176,7 @@ 1,1,1,0 0,0,0,2 - 1,1,1,1 + 1,1,1,0 1,1,1,1 diff --git a/Flow.Launcher/Resources/Pages/WelcomePage1.xaml b/Flow.Launcher/Resources/Pages/WelcomePage1.xaml index 1728195bd..b6a99d9e9 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage1.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage1.xaml @@ -110,8 +110,8 @@ - - + + @@ -127,7 +127,7 @@ Style="{DynamicResource StyleImageFadeIn}" /> - - + + (); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); + protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); - } - private Internationalization _translater => InternationalizationManager.Instance; - public List Languages => _translater.LoadAvailableLanguages(); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 1; - public Settings Settings { get; set; } + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); + } + + private readonly Internationalization _translater = Ioc.Default.GetRequiredService(); + + public List Languages => _translater.LoadAvailableLanguages(); public string CustomLanguage { @@ -29,12 +37,11 @@ namespace Flow.Launcher.Resources.Pages } set { - InternationalizationManager.Instance.ChangeLanguage(value); + _translater.ChangeLanguage(value); - if (InternationalizationManager.Instance.PromptShouldUsePinyin(value)) + if (_translater.PromptShouldUsePinyin(value)) Settings.ShouldUsePinyin = true; } } - } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml index 6c6fcbb62..ca00091f5 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml @@ -56,11 +56,11 @@ Margin="0" Background="{Binding PreviewBackground}"> - + @@ -89,33 +89,32 @@ - + diff --git a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs index 1ed5747cd..37767f128 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs @@ -1,26 +1,30 @@ -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure.Hotkey; -using Flow.Launcher.Infrastructure.UserSettings; -using System; +using System.Windows.Media; using System.Windows.Navigation; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Helper; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.ViewModel; -using System.Windows.Media; namespace Flow.Launcher.Resources.Pages { public partial class WelcomePage2 { - public Settings Settings { get; set; } + public Settings Settings { get; } = Ioc.Default.GetRequiredService(); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Parameter setting."); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 2; - InitializeComponent(); + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); } [RelayCommand] diff --git a/Flow.Launcher/Resources/Pages/WelcomePage3.xaml b/Flow.Launcher/Resources/Pages/WelcomePage3.xaml index a9e3fa696..0c1dcfea0 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage3.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage3.xaml @@ -13,15 +13,15 @@ @@ -81,26 +81,26 @@ Canvas.Left="0" Width="450" Height="280" - Margin="0,0,0,0" + Margin="0 0 0 0" Source="../../images/page_img01.png" Style="{DynamicResource StyleImageFadeIn}" /> - + diff --git a/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs b/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs index 11bbcd6ed..63c9b9a7a 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs +++ b/Flow.Launcher/Resources/Pages/WelcomePage4.xaml.cs @@ -1,20 +1,26 @@ -using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Windows.Navigation; +using System.Windows.Navigation; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.ViewModel; namespace Flow.Launcher.Resources.Pages { public partial class WelcomePage4 { + public Settings Settings { get; } = Ioc.Default.GetRequiredService(); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); + protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); - } + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 4; - public Settings Settings { get; set; } + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); + } } } diff --git a/Flow.Launcher/Resources/Pages/WelcomePage5.xaml b/Flow.Launcher/Resources/Pages/WelcomePage5.xaml index c898ac9a0..7495231ae 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage5.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage5.xaml @@ -58,10 +58,10 @@ - + - - + + @@ -79,18 +79,18 @@ - + - + - + (); + private readonly WelcomeViewModel _viewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.ExtraData is Settings settings) - Settings = settings; - else - throw new ArgumentException("Unexpected Navigation Parameter for Settings"); - InitializeComponent(); + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page number + _viewModel.PageNum = 5; + + if (!IsInitialized) + { + InitializeComponent(); + } + base.OnNavigatedTo(e); } private void OnAutoStartupChecked(object sender, RoutedEventArgs e) { - SetStartup(); - } - private void OnAutoStartupUncheck(object sender, RoutedEventArgs e) - { - RemoveStartup(); + ChangeAutoStartup(true); } - private void RemoveStartup() + private void OnAutoStartupUncheck(object sender, RoutedEventArgs e) { - using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); - key?.DeleteValue(Constant.FlowLauncher, false); - Settings.StartFlowLauncherOnSystemStartup = false; + ChangeAutoStartup(false); } - private void SetStartup() + + private void ChangeAutoStartup(bool value) { - using var key = Registry.CurrentUser.OpenSubKey(StartupPath, true); - key?.SetValue(Constant.FlowLauncher, Constant.ExecutablePath); - Settings.StartFlowLauncherOnSystemStartup = true; + Settings.StartFlowLauncherOnSystemStartup = value; + try + { + if (value) + { + if (Settings.UseLogonTaskForStartup) + { + AutoStartup.ChangeToViaLogonTask(); + } + else + { + AutoStartup.ChangeToViaRegistry(); + } + } + else + { + AutoStartup.DisableViaLogonTaskAndRegistry(); + } + } + catch (Exception e) + { + App.API.ShowMsg(App.API.GetTranslation("setAutoStartFailed"), e.Message); + } } private void OnHideOnStartupChecked(object sender, RoutedEventArgs e) { Settings.HideOnStartup = true; } + private void OnHideOnStartupUnchecked(object sender, RoutedEventArgs e) { Settings.HideOnStartup = false; @@ -58,6 +78,5 @@ namespace Flow.Launcher.Resources.Pages var window = Window.GetWindow(this); window.Close(); } - } } diff --git a/Flow.Launcher/ResultListBox.xaml b/Flow.Launcher/ResultListBox.xaml index e26340c4f..8cb15400f 100644 --- a/Flow.Launcher/ResultListBox.xaml +++ b/Flow.Launcher/ResultListBox.xaml @@ -32,6 +32,8 @@ + + @@ -65,23 +67,24 @@ Margin="0 0 10 0" VerticalAlignment="Center" Visibility="{Binding ShowOpenResultHotkey}"> - - + + - - - - - - - - + + + + + + + + + + @@ -89,60 +92,64 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SelectBrowserWindow.xaml b/Flow.Launcher/SelectBrowserWindow.xaml index c12879a04..d51d597b7 100644 --- a/Flow.Launcher/SelectBrowserWindow.xaml +++ b/Flow.Launcher/SelectBrowserWindow.xaml @@ -6,10 +6,11 @@ xmlns:local="clr-namespace:Flow.Launcher" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.modernwpf.com/2019" + xmlns:vm="clr-namespace:Flow.Launcher.ViewModel" Title="{DynamicResource defaultBrowserTitle}" Width="550" + d:DataContext="{d:DesignInstance vm:SelectBrowserViewModel}" Background="{DynamicResource PopuBGColor}" - DataContext="{Binding RelativeSource={RelativeSource Self}}" Foreground="{DynamicResource PopupTextColor}" ResizeMode="NoResize" SizeToContent="Height" @@ -54,11 +55,11 @@ - - + + - + - - - + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs index db4763319..c0a77957a 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs @@ -1,8 +1,8 @@ -using System; -using System.ComponentModel; +using System.ComponentModel; using System.Windows.Data; using System.Windows.Input; using System.Windows.Navigation; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.SettingPages.ViewModels; using Flow.Launcher.ViewModel; @@ -11,15 +11,22 @@ namespace Flow.Launcher.SettingPages.Views; public partial class SettingsPanePluginStore { private SettingsPanePluginStoreViewModel _viewModel = null!; + private readonly SettingWindowViewModel _settingViewModel = Ioc.Default.GetRequiredService(); protected override void OnNavigatedTo(NavigationEventArgs e) { + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page type + _settingViewModel.PageType = typeof(SettingsPanePluginStore); + + // If the navigation is not triggered by button click, view model will be null again + if (_viewModel == null) + { + _viewModel = Ioc.Default.GetRequiredService(); + DataContext = _viewModel; + } if (!IsInitialized) { - if (e.ExtraData is not SettingWindow.PaneData { Settings: { } settings }) - throw new ArgumentException($"Settings are required for {nameof(SettingsPanePluginStore)}."); - _viewModel = new SettingsPanePluginStoreViewModel(); - DataContext = _viewModel; InitializeComponent(); } _viewModel.PropertyChanged += ViewModel_PropertyChanged; @@ -28,9 +35,15 @@ public partial class SettingsPanePluginStore private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(SettingsPanePluginStoreViewModel.FilterText)) + switch (e.PropertyName) { - ((CollectionViewSource)FindResource("PluginStoreCollectionView")).View.Refresh(); + case nameof(SettingsPanePluginStoreViewModel.FilterText): + case nameof(SettingsPanePluginStoreViewModel.ShowDotNet): + case nameof(SettingsPanePluginStoreViewModel.ShowPython): + case nameof(SettingsPanePluginStoreViewModel.ShowNodeJs): + case nameof(SettingsPanePluginStoreViewModel.ShowExecutable): + ((CollectionViewSource)FindResource("PluginStoreCollectionView")).View.Refresh(); + break; } } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml index 37079a46f..52d77f914 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml @@ -2,21 +2,24 @@ x:Class="Flow.Launcher.SettingPages.Views.SettingsPanePlugins" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:ui="http://schemas.modernwpf.com/2019" xmlns:viewModels="clr-namespace:Flow.Launcher.SettingPages.ViewModels" - xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" Title="Plugins" - FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" - KeyDown="SettingsPanePlugins_OnKeyDown" d:DataContext="{d:DesignInstance viewModels:SettingsPanePluginsViewModel}" d:DesignHeight="450" d:DesignWidth="800" + FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" + KeyDown="SettingsPanePlugins_OnKeyDown" mc:Ignorable="d"> - + @@ -31,61 +34,96 @@ Style="{StaticResource PageTitle}" Text="{DynamicResource plugins}" TextAlignment="Left" /> - - - - - + Orientation="Horizontal"> + + + + + + + + + - + + (); protected override void OnNavigatedTo(NavigationEventArgs e) { + // Sometimes the navigation is not triggered by button click, + // so we need to reset the page type + _settingViewModel.PageType = typeof(SettingsPanePlugins); + + // If the navigation is not triggered by button click, view model will be null again + if (_viewModel == null) + { + _viewModel = Ioc.Default.GetRequiredService(); + DataContext = _viewModel; + } if (!IsInitialized) { - if (e.ExtraData is not SettingWindow.PaneData { Settings: { } settings }) - throw new ArgumentException("Settings are required for SettingsPaneHotkey."); - _viewModel = new SettingsPanePluginsViewModel(settings); - DataContext = _viewModel; InitializeComponent(); } + _viewModel.PropertyChanged += ViewModel_PropertyChanged; base.OnNavigatedTo(e); } + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsPanePluginsViewModel.FilterText)) + { + ((CollectionViewSource)FindResource("PluginCollectionView")).View.Refresh(); + } + } + + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + base.OnNavigatingFrom(e); + } + private void SettingsPanePlugins_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key is not Key.F || Keyboard.Modifiers is not ModifierKeys.Control) return; PluginFilterTextbox.Focus(); } + + private void PluginCollectionView_OnFilter(object sender, FilterEventArgs e) + { + if (e.Item is not PluginViewModel plugin) + { + e.Accepted = false; + return; + } + + e.Accepted = _viewModel.SatisfiesFilter(plugin); + } } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml index 768abbf97..f429a6e29 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneProxy.xaml @@ -2,11 +2,11 @@ x:Class="Flow.Launcher.SettingPages.Views.SettingsPaneProxy" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:ui="http://schemas.modernwpf.com/2019" - xmlns:cc="clr-namespace:Flow.Launcher.Resources.Controls" xmlns:viewModels="clr-namespace:Flow.Launcher.SettingPages.ViewModels" Title="Proxy" d:DataContext="{d:DesignInstance viewModels:SettingsPaneProxyViewModel}" @@ -18,8 +18,8 @@ @@ -71,9 +71,9 @@ - +