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/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index a36a6af3e..60142b346 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -6,3 +6,6 @@ runcount Firefox Português Português (Brasil) +favicons +moz +workaround diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 6ba41e410..81b9aba97 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,3 +1,5 @@ +# This file should contain names of products, companies, or individuals that aren't in a standard dictionary (e.g., GitHub, Keptn, VSCode). + crowdin DWM workflows @@ -34,7 +36,6 @@ mscorlib pythonw dotnet winget -jjw24 wolframalpha gmail duckduckgo @@ -49,7 +50,6 @@ srchadmin EWX dlgtext CMD -appref-ms appref TSource runas @@ -57,7 +57,6 @@ dpi popup ptr pluginindicator -TobiasSekan img resx bak @@ -68,9 +67,6 @@ dlg ddd dddd clearlogfolder -ACCENT_ENABLE_TRANSPARENTGRADIENT -ACCENT_ENABLE_BLURBEHIND -WCA_ACCENT_POLICY HGlobal dopusrt firefox @@ -91,22 +87,24 @@ keyevent KListener requery vkcode -čeština Polski Srpski -Português -Português (Brasil) Italiano -Slovenský quicklook -Tiếng Việt Droplex Preinstalled errormetadatafile noresult pluginsmanager alreadyexists -JsonRPC -JsonRPCV2 Softpedia img +Reloadable +metadatas +WMP +VSTHRD +CJK +Msix +dummyprofile +browserbookmark +copyurl diff --git a/.github/actions/spelling/patterns.txt b/.github/actions/spelling/patterns.txt index f29f57ad5..faf1c3978 100644 --- a/.github/actions/spelling/patterns.txt +++ b/.github/actions/spelling/patterns.txt @@ -1,4 +1,6 @@ # See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns +# This file should contain strings that contain a mix of letters and numbers, or specific symbols + # Questionably acceptable forms of `in to` # Personally, I prefer `log into`, but people object @@ -121,3 +123,28 @@ # version suffix v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) + +# Non-English +[a-zA-Z]*[ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]* + + +\bjjw24\b +\bappref-ms\b +\bTobiasSekan\b +\bJsonRPC\b +\bJsonRPCV2\b +\bTiếng Việt\b +\bPortuguês (Brasil)\b +\bčeština\b +\bPortuguês\b +\bIoc\b +\bXiao\s*He\b +\bZi\s*Ran\s*Ma\b +\bWei\s*Ruan\b +\bZhi\s*Neng\s*ABC\b +\bZi\s*Guang\s*Pin\s*Yin\b +\bPin\s*Yin\s*Jia\s*Jia\b +\bXing\s*Kong\s*Jian\s*Dao\b +\bDa\s*Niu\b +\bXiao\s*Lang\b +\b[Ss]ettings [Ss]ettings\b diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d9b39eb89..da4231f74 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,8 @@ updates: - package-ecosystem: "nuget" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" + open-pull-requests-limit: 3 ignore: - dependency-name: "squirrel-windows" reviewers: diff --git a/.github/update_release_pr.py b/.github/update_release_pr.py new file mode 100644 index 000000000..be523bfe8 --- /dev/null +++ b/.github/update_release_pr.py @@ -0,0 +1,240 @@ +from os import getenv +from typing import Optional + +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 label and state. + + 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", + } + + # This endpoint allows filtering by label(and milestone). 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, + "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", milestone_title: Optional[str] = None +) -> 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 + milestone_title (Optional[str]): The milestone title to filter by. This is the milestone number you created + in GitHub, e.g. '1.20.0'. If None, no milestone filtering is applied. + + 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 not in [pr["state"], "all"]: + continue + + if label and not [item for item in pr["labels"] if item["name"] == label]: + continue + + if milestone_title: + if pr["milestone"] is None or pr["milestone"]["title"] != milestone_title: + continue + + pr_list.append(pr) + count += 1 + + print( + f"Found {count} PRs with {label if label else 'no filter on'} label, state as {state}, and milestone {milestone_title if milestone_title else "any"}" + ) + + return pr_list + + +def get_prs_assignees(pull_request_items: list[dict]) -> list[str]: + """ + Returns a list of pull request assignees, excludes jjw24. + + Args: + pull_request_items (list[dict]): List of PR items to get the assignees from. + + 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: + [assignee_list.append(assignee["login"]) for assignee in pr["assignees"] if assignee["login"] != "jjw24"] + + print(f"Found {len(assignee_list)} assignees") + + 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} ...") + + # First, get all PRs to find the release PR and determine the milestone + all_pull_requests = get_github_prs(github_token, repository_owner, repository_name) + + if not all_pull_requests: + print("No pull requests found") + exit(1) + + print(f"\nFound total of {len(all_pull_requests)} pull requests") + + release_pr = get_prs(all_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']}") + + release_milestone_title = release_pr[0].get("milestone", {}).get("title", None) + + if not release_milestone_title: + print("Release PR does not have a milestone assigned.") + exit(1) + + print(f"Using milestone number: {release_milestone_title}") + + enhancement_prs = get_prs(all_pull_requests, "enhancement", "closed", release_milestone_title) + bug_fix_prs = get_prs(all_pull_requests, "bug", "closed", release_milestone_title) + + if len(enhancement_prs) == 0 and len(bug_fix_prs) == 0: + print(f"No PRs with {release_milestone_title} milestone were found") + + 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(enhancement_prs) + get_prs_assignees(bug_fix_prs))) + 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..83e830d75 100644 --- a/.github/workflows/default_plugins.yml +++ b/.github/workflows/default_plugins.yml @@ -3,53 +3,37 @@ name: Publish Default Plugins on: push: branches: ['master'] - paths: ['Plugins/**'] workflow_dispatch: jobs: - build: + publish: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 7.0.x + dotnet-version: 9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Calculator/Flow.Launcher.Plugin.Calculator.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginIndicator/Flow.Launcher.Plugin.PluginIndicator.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.ProcessKiller/Flow.Launcher.Plugin.ProcessKiller.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.Url/Flow.Launcher.Plugin.Url.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.WebSearch/Flow.Launcher.Plugin.WebSearch.csproj' --framework net9.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" + dotnet publish 'Plugins/Flow.Launcher.Plugin.WindowsSettings/Flow.Launcher.Plugin.WindowsSettings.csproj' --framework net9.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..957f836bb --- /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.20.2 + NUGET_CERT_REVOCATION_MODE: offline + BUILD_NUMBER: ${{ github.run_number }} + steps: + - uses: actions/checkout@v5 + - 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@v5 + with: + dotnet-version: 9.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..58a877ba3 --- /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@v5 + + - uses: actions/setup-python@v6 + 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..904392bf0 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: @@ -73,7 +72,7 @@ jobs: steps: - name: check-spelling id: spelling - uses: check-spelling/check-spelling@prerelease + uses: check-spelling/check-spelling@v0.0.25 with: suppress_push_for_open_pull_request: 1 checkout: true @@ -129,7 +128,7 @@ jobs: if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request') steps: - name: comment - uses: check-spelling/check-spelling@prerelease + uses: check-spelling/check-spelling@v0.0.25 with: checkout: true spell_check_this: check-spelling/spell-check-this@main diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 719f2556e..5652eef5e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: stale-issue-message: 'This issue is stale because it has been open ${{ env.days-before-stale }} days with no activity. Remove stale label or comment or this will be closed in ${{ env.days-before-stale }} days.\n\nAlternatively this issue can be kept open by adding one of the following labels:\n${{ env.exempt-issue-labels }}' days-before-stale: ${{ env.days-before-stale }} diff --git a/Directory.Build.props b/Directory.Build.props index fa499273c..a5545af12 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,7 @@ true + + false \ No newline at end of file diff --git a/Flow.Launcher.Core/Configuration/Portable.cs b/Flow.Launcher.Core/Configuration/Portable.cs index d7c73fb46..721e14dca 100644 --- a/Flow.Launcher.Core/Configuration/Portable.cs +++ b/Flow.Launcher.Core/Configuration/Portable.cs @@ -1,24 +1,29 @@ -using Microsoft.Win32; -using Squirrel; -using System; +using System; using System.IO; +using System.Linq; using System.Reflection; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; -using System.Linq; +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) @@ -40,14 +45,13 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.PortableDataPath); - MessageBoxEx.Show("Flow Launcher needs to restart to finish disabling portable mode, " + - "after the restart your portable data profile will be deleted and roaming data profile kept"); + API.ShowMsgBox(API.GetTranslation("restartToDisablePortableMode")); UpdateManager.RestartApp(Constant.ApplicationFileName); } catch (Exception e) { - Log.Exception("|Portable.DisablePortableMode|Error occurred while disabling portable mode", e); + API.LogException(ClassName, "Error occurred while disabling portable mode", e); } } @@ -64,54 +68,47 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.RoamingDataPath); - MessageBoxEx.Show("Flow Launcher needs to restart to finish enabling portable mode, " + - "after the restart your roaming data profile will be deleted and portable data profile kept"); + API.ShowMsgBox(API.GetTranslation("restartToEnablePortableMode")); UpdateManager.RestartApp(Constant.ApplicationFileName); } 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) { - FilesFolders.CopyAll(fromLocation, toLocation, MessageBoxEx.Show); + FilesFolders.CopyAll(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); VerifyUserDataAfterMove(fromLocation, toLocation); } public void VerifyUserDataAfterMove(string fromLocation, string toLocation) { - FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation, MessageBoxEx.Show); + FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); } 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() @@ -125,18 +122,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); } /// @@ -157,13 +150,12 @@ namespace Flow.Launcher.Core.Configuration // delete it and prompt the user to pick the portable data location if (File.Exists(roamingDataDeleteFilePath)) { - FilesFolders.RemoveFolderIfExists(roamingDataDir, MessageBoxEx.Show); + FilesFolders.RemoveFolderIfExists(roamingDataDir, (s) => API.ShowMsgBox(s)); - if (MessageBoxEx.Show("Flow Launcher has detected you enabled portable mode, " + - "would you like to move it to a different location?", string.Empty, - MessageBoxButton.YesNo) == MessageBoxResult.Yes) + if (API.ShowMsgBox(API.GetTranslation("moveToDifferentLocation"), + string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes) { - FilesFolders.OpenPath(Constant.RootDirectory, MessageBoxEx.Show); + FilesFolders.OpenPath(Constant.RootDirectory, (s) => API.ShowMsgBox(s)); Environment.Exit(0); } @@ -172,10 +164,9 @@ namespace Flow.Launcher.Core.Configuration // delete it and notify the user about it. else if (File.Exists(portableDataDeleteFilePath)) { - FilesFolders.RemoveFolderIfExists(portableDataDir, MessageBoxEx.Show); + FilesFolders.RemoveFolderIfExists(portableDataDir, (s) => API.ShowMsgBox(s)); - MessageBoxEx.Show("Flow Launcher has detected you disabled portable mode, " + - "the relevant shortcuts and uninstaller entry have been created"); + API.ShowMsgBox(API.GetTranslation("shortcutsUninstallerCreated")); } } @@ -186,9 +177,8 @@ namespace Flow.Launcher.Core.Configuration if (roamingLocationExists && portableLocationExists) { - MessageBoxEx.Show(string.Format("Flow Launcher detected your user data exists both in {0} and " + - "{1}. {2}{2}Please delete {1} in order to proceed. No changes have occurred.", - DataLocation.PortableDataPath, DataLocation.RoamingDataPath, Environment.NewLine)); + API.ShowMsgBox(string.Format(API.GetTranslation("userDataDuplicated"), + DataLocation.PortableDataPath, DataLocation.RoamingDataPath, Environment.NewLine)); return false; } diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs index 68be746f2..841099dd1 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,60 @@ 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 (OperationCanceledException) when (token.IsCancellationRequested) { - Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified."); - return this.plugins; + API.LogDebug(ClassName, $"Fetching from {ManifestFileUrl} was cancelled by caller."); + return null; } - else + catch (TaskCanceledException) { - 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}"); + // Likely an HttpClient timeout or external cancellation not requested by our token + API.LogWarn(ClassName, $"Fetching from {ManifestFileUrl} timed out."); + return null; + } + catch (Exception e) + { + 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 6d41e2383..14796a87a 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs @@ -1,18 +1,22 @@ -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; } internal abstract string EnvName { get; } @@ -25,7 +29,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments internal virtual string FileDialogFilter => string.Empty; - internal abstract string PluginsSettingsFilePath { get; set; } + internal abstract string PluginsSettingsFilePath { get; set; } internal List PluginMetadataList; @@ -39,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)) { @@ -52,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 (MessageBoxEx.Show(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + 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 { @@ -82,8 +120,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } else { - MessageBoxEx.Show(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"); @@ -95,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, MessageBoxEx.Show); + FilesFolders.RemoveFolderIfExists(installedDirPath, (s) => API.ShowMsgBox(s)); InstallEnvironment(); - } internal abstract PluginPair CreatePluginPair(string filePath, PluginMetadata metadata); @@ -113,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 { @@ -133,7 +172,6 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments var result = dlg.ShowDialog(); return result == DialogResult.OK ? dlg.FileName : string.Empty; - } /// @@ -176,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); } @@ -210,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 96c29646e..89286dfb0 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs @@ -1,15 +1,18 @@ -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 { internal class PythonEnvironment : AbstractPluginEnvironment { + private static readonly string ClassName = nameof(PythonEnvironment); + internal override string Language => AllowedLanguage.Python; internal override string EnvName => DataLocation.PythonEnvironmentName; @@ -22,19 +25,36 @@ 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, MessageBoxEx.Show); + 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(async () => + { + try + { + await DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(API.GetTranslation("failToInstallPythonEnv")); + API.LogException(ClassName, "Failed to install Python environment", e); + } + }); } internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs index 0d6f109e0..724ae20f4 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs @@ -1,15 +1,18 @@ 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 { internal class TypeScriptEnvironment : AbstractPluginEnvironment { + private static readonly string ClassName = nameof(TypeScriptEnvironment); + internal override string Language => AllowedLanguage.TypeScript; internal override string EnvName => DataLocation.NodeEnvironmentName; @@ -19,17 +22,34 @@ 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, MessageBoxEx.Show); + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(async () => + { + try + { + await DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(API.GetTranslation("failToInstallTypeScriptEnv")); + API.LogException(ClassName, "Failed to install TypeScript environment", e); + } + }); } internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs index 582a4407c..6a32664a1 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs @@ -1,15 +1,18 @@ 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 { internal class TypeScriptV2Environment : AbstractPluginEnvironment { + private static readonly string ClassName = nameof(TypeScriptV2Environment); + internal override string Language => AllowedLanguage.TypeScriptV2; internal override string EnvName => DataLocation.NodeEnvironmentName; @@ -19,17 +22,34 @@ 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, MessageBoxEx.Show); + FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait(); + JTF.Run(async () => + { + try + { + await DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(API.GetTranslation("failToInstallTypeScriptEnv")); + API.LogException(ClassName, "Failed to install TypeScript environment", e); + } + }); } internal override PluginPair CreatePluginPair(string filePath, PluginMetadata metadata) diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index ac8abcdcc..1e845498c 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,27 +1,35 @@ -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; +using Flow.Launcher.Infrastructure; 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", - "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", - "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"); + new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/main/plugins.json", + "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", + "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", + "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json"); 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); + + // 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 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 { @@ -32,18 +40,28 @@ namespace Flow.Launcher.Core.ExternalPlugins var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); // If the results are empty, we shouldn't update the manifest because the results are invalid. - if (results.Count != 0) - { - UserPlugins = results; - lastFetchedAt = DateTime.Now; + if (results.Count == 0) + return false; - return true; + var updatedPluginResults = new List(); + var appVersion = SemanticVersioning.Version.Parse(Constant.Version); + + for (int i = 0; i < results.Count; i++) + { + if (IsMinimumAppVersionSatisfied(results[i], appVersion)) + updatedPluginResults.Add(results[i]); } + + UserPlugins = updatedPluginResults; + + lastFetchedAt = DateTime.Now; + + return true; } } catch (Exception e) { - Log.Exception($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http request failed", e); + API.LogException(ClassName, "Http request failed", e); } finally { @@ -52,5 +70,28 @@ namespace Flow.Launcher.Core.ExternalPlugins return false; } + + private static bool IsMinimumAppVersionSatisfied(UserPlugin plugin, SemanticVersioning.Version appVersion) + { + if (string.IsNullOrEmpty(plugin.MinimumAppVersion)) + return true; + + try + { + if (appVersion >= SemanticVersioning.Version.Parse(plugin.MinimumAppVersion)) + return true; + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to parse the minimum app version {plugin.MinimumAppVersion} for plugin {plugin.Name}. " + + "Plugin excluded from manifest", e); + return false; + } + + API.LogInfo(ClassName, $"Plugin {plugin.Name} requires minimum Flow Launcher version {plugin.MinimumAppVersion}, " + + $"but current version is {Constant.Version}. Plugin excluded from manifest."); + + return false; + } } } 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/Flow.Launcher.Core.csproj b/Flow.Launcher.Core/Flow.Launcher.Core.csproj index df2f4d2cb..1369d7e5d 100644 --- a/Flow.Launcher.Core/Flow.Launcher.Core.csproj +++ b/Flow.Launcher.Core/Flow.Launcher.Core.csproj @@ -1,7 +1,7 @@ - net7.0-windows + net9.0-windows true true Library @@ -12,6 +12,7 @@ false false en + true @@ -54,11 +55,12 @@ - - + + + - + 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 f6e5e5879..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 { @@ -34,18 +17,18 @@ namespace Flow.Launcher.Core.Plugin /// Represent the plugin that using JsonPRC /// every JsonRPC plugin should has its own plugin instance /// - internal abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable + 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 SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, - Context.CurrentPluginMetadata.Name, "Settings.json"); + private string SettingDirectory => Context.CurrentPluginMetadata.PluginSettingsDirectoryPath; + + private string SettingPath => Path.Combine(SettingDirectory, "Settings.json"); public abstract List LoadContextMenus(Result selectedResult); @@ -123,7 +106,6 @@ namespace Flow.Launcher.Core.Plugin public abstract Task> QueryAsync(Query query, CancellationToken token); - private async Task InitSettingAsync() { JsonRpcConfigurationModel configuration = null; @@ -135,7 +117,6 @@ namespace Flow.Launcher.Core.Plugin await File.ReadAllTextAsync(SettingConfigurationPath)); } - Settings ??= new JsonRPCPluginSettings { Configuration = configuration, SettingPath = SettingPath, API = Context.API @@ -146,7 +127,7 @@ namespace Flow.Launcher.Core.Plugin public virtual async Task InitAsync(PluginInitContext context) { - this.Context = context; + Context = context; await InitSettingAsync(); } @@ -155,6 +136,11 @@ namespace Flow.Launcher.Core.Plugin Settings?.Save(); } + public bool NeedCreateSettingPanel() + { + return Settings.NeedCreateSettingPanel(); + } + public Control CreateSettingPanel() { return Settings.CreateSettingPanel(); diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs index 50eb30998..9212dada6 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -1,73 +1,93 @@ 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 { - public class JsonRPCPluginSettings + public class JsonRPCPluginSettings : ISavable { public required JsonRpcConfigurationModel? Configuration { get; init; } 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 double MainGridColumn0MaxWidthRatio = 0.6; + 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 +98,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,341 +125,406 @@ 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 + return Settings != null && Configuration != null && Configuration.Body.Count != 0; } public Control CreateSettingPanel() { - if (Settings == null || Settings.Count == 0) - return new(); + 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 0: Auto, Column 1: *) + 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.Wrap + }; + + // Create a text block for description + TextBlock? desc = null; + if (attributes.Description != null) + { + desc = new TextBlock() + { + Text = attributes.Description, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap + }; + + 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, + TextWrapping = TextWrapping.Wrap + }; - 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, + TextWrapping = TextWrapping.Wrap }; - 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.Wrap, + 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; + mainPanel.SizeChanged += MainPanel_SizeChanged; + + // Wrap the main grid in a user control + return new UserControl() + { + Content = mainPanel + }; + } + + private void MainPanel_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (sender is not Grid grid) return; + + var workingWidth = grid.ActualWidth; + + if (workingWidth <= 0) return; + + var constrainedWidth = MainGridColumn0MaxWidthRatio * workingWidth; + + // Set MaxWidth of column 0 and its children + // We must set MaxWidth of its children to make text wrapping work correctly + grid.ColumnDefinitions[0].MaxWidth = constrainedWidth; + foreach (var child in grid.Children) + { + if (child is FrameworkElement element && Grid.GetColumn(element) == 0 && Grid.GetColumnSpan(element) == 1) + { + element.MaxWidth = constrainedWidth; + } + } + } + + 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 305b28150..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 }; @@ -112,10 +110,24 @@ namespace Flow.Launcher.Core.Plugin RPC.StartListening(); } - public virtual Task ReloadDataAsync() + public virtual async Task ReloadDataAsync() { - SetupJsonRPC(); - return Task.CompletedTask; + try + { + await RPC.InvokeAsync("reload_data", Context); + } + catch (RemoteMethodNotFoundException) + { + // Ignored + } + catch (ConnectionLostException) + { + // Ignored + } + catch (Exception e) + { + Context.API.LogException(ClassName, $"Failed to call reload_data for plugin {Context.CurrentPluginMetadata.Name}", e); + } } public virtual async ValueTask DisposeAsync() @@ -124,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 e0a0434a2..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); @@ -175,5 +179,20 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models { _api.BackToQueryResults(); } + + public void StartLoadingBar() + { + _api.StartLoadingBar(); + } + + public void StopLoadingBar() + { + _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/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs new file mode 100644 index 000000000..d01b34ab6 --- /dev/null +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Core.Plugin; + +/// +/// Class for installing, updating, and uninstalling plugins. +/// +public static class PluginInstaller +{ + private static readonly string ClassName = nameof(PluginInstaller); + + private static readonly Settings Settings = Ioc.Default.GetRequiredService(); + + // 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(); + + /// + /// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed. + /// + /// The plugin to install. + /// A Task representing the asynchronous install operation. + public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin) + { + if (API.PluginModified(newPlugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), newPlugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("InstallPromptSubtitle"), + newPlugin.Name, newPlugin.Author, Environment.NewLine), + API.GetTranslation("InstallPromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + try + { + // at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download + var downloadFilename = string.IsNullOrEmpty(newPlugin.Version) + ? $"{newPlugin.Name}-{Guid.NewGuid()}.zip" + : $"{newPlugin.Name}-{newPlugin.Version}.zip"; + + var filePath = Path.Combine(Path.GetTempPath(), downloadFilename); + + using var cts = new CancellationTokenSource(); + + if (!newPlugin.IsFromLocalInstallPath) + { + await DownloadFileAsync( + $"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}", + newPlugin.UrlDownload, filePath, cts); + } + else + { + filePath = newPlugin.LocalInstallPath; + } + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath); + } + + if (!API.InstallPlugin(newPlugin, filePath)) + { + return; + } + + if (!newPlugin.IsFromLocalInstallPath) + { + File.Delete(filePath); + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to install plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin")); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("installbtn"), + string.Format( + API.GetTranslation( + "InstallSuccessNoRestart"), + newPlugin.Name)); + } + } + + /// + /// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation. + /// + /// The path to the plugin zip file. + /// A Task representing the asynchronous install operation. + public static async Task InstallPluginAndCheckRestartAsync(string filePath) + { + UserPlugin plugin; + try + { + using ZipArchive archive = ZipFile.OpenRead(filePath); + var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json") ?? + throw new FileNotFoundException("The zip file does not contain a plugin.json file."); + + using Stream stream = pluginJsonEntry.Open(); + plugin = JsonSerializer.Deserialize(stream); + plugin.IcoPath = "Images\\zipfolder.png"; + plugin.LocalInstallPath = filePath; + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to validate zip file", e); + API.ShowMsgError(API.GetTranslation("ZipFileNotHavePluginJson")); + return; + } + + if (API.PluginModified(plugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (Settings.ShowUnknownSourceWarning) + { + if (!InstallSourceKnown(plugin.Website) + && API.ShowMsgBox(string.Format( + API.GetTranslation("InstallFromUnknownSourceSubtitle"), Environment.NewLine), + API.GetTranslation("InstallFromUnknownSourceTitle"), + MessageBoxButton.YesNo) == MessageBoxResult.No) + return; + } + + await InstallPluginAndCheckRestartAsync(plugin); + } + + /// + /// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings. + /// + /// The plugin metadata to uninstall. + /// A Task representing the asynchronous uninstall operation. + public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin) + { + if (API.PluginModified(oldPlugin.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), oldPlugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return; + } + + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("UninstallPromptSubtitle"), + oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + API.GetTranslation("UninstallPromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + var removePluginSettings = API.ShowMsgBox( + API.GetTranslation("KeepPluginSettingsSubtitle"), + API.GetTranslation("KeepPluginSettingsTitle"), + button: MessageBoxButton.YesNo) == MessageBoxResult.No; + + try + { + if (!await API.UninstallPluginAsync(oldPlugin, removePluginSettings)) + { + return; + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to uninstall plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin")); + return; // don not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("uninstallbtn"), + string.Format( + API.GetTranslation( + "UninstallSuccessNoRestart"), + oldPlugin.Name)); + } + } + + /// + /// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed. + /// + /// The new plugin version to install. + /// The existing plugin metadata to update. + /// A Task representing the asynchronous update operation. + public static async Task UpdatePluginAndCheckRestartAsync(UserPlugin newPlugin, PluginMetadata oldPlugin) + { + if (API.ShowMsgBox( + string.Format( + API.GetTranslation("UpdatePromptSubtitle"), + oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + API.GetTranslation("UpdatePromptTitle"), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + try + { + var filePath = Path.Combine(Path.GetTempPath(), $"{newPlugin.Name}-{newPlugin.Version}.zip"); + + using var cts = new CancellationTokenSource(); + + if (!newPlugin.IsFromLocalInstallPath) + { + await DownloadFileAsync( + $"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}", + newPlugin.UrlDownload, filePath, cts); + } + else + { + filePath = newPlugin.LocalInstallPath; + } + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath)) + { + return; + } + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to update plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin")); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("updatebtn"), + string.Format( + API.GetTranslation( + "UpdateSuccessNoRestart"), + newPlugin.Name)); + } + } + + /// + /// Updates the plugin to the latest version available from its source. + /// + /// Action to execute when the user chooses to update all plugins. + /// If true, do not show any messages when there is no update available. + /// If true, only use the primary URL for updates. + /// Cancellation token to cancel the update operation. + /// + public static async Task CheckForPluginUpdatesAsync(Action> updateAllPlugins, bool silentUpdate = true, bool usePrimaryUrlOnly = false, CancellationToken token = default) + { + // Update the plugin manifest + await API.UpdatePluginManifestAsync(usePrimaryUrlOnly, token); + + // Get all plugins that can be updated + var resultsForUpdate = ( + from existingPlugin in API.GetAllPlugins() + join pluginUpdateSource in API.GetPluginManifest() + on existingPlugin.Metadata.ID equals pluginUpdateSource.ID + where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version, + StringComparison.InvariantCulture) < + 0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest) + && !API.PluginModified(existingPlugin.Metadata.ID) + select + new PluginUpdateInfo() + { + ID = existingPlugin.Metadata.ID, + Name = existingPlugin.Metadata.Name, + Author = existingPlugin.Metadata.Author, + CurrentVersion = existingPlugin.Metadata.Version, + NewVersion = pluginUpdateSource.Version, + IcoPath = existingPlugin.Metadata.IcoPath, + PluginExistingMetadata = existingPlugin.Metadata, + PluginNewUserPlugin = pluginUpdateSource + }).ToList(); + + // No updates + if (!resultsForUpdate.Any()) + { + if (!silentUpdate) + { + API.ShowMsg(API.GetTranslation("updateNoResultTitle"), API.GetTranslation("updateNoResultSubtitle")); + } + return; + } + + // If all plugins are modified, just return + if (resultsForUpdate.All(x => API.PluginModified(x.ID))) + { + return; + } + + // Show message box with button to update all plugins + API.ShowMsgWithButton( + API.GetTranslation("updateAllPluginsTitle"), + API.GetTranslation("updateAllPluginsButtonContent"), + () => + { + updateAllPlugins(resultsForUpdate); + }, + string.Join(", ", resultsForUpdate.Select(x => x.PluginExistingMetadata.Name))); + } + + /// + /// Updates all plugins that have available updates. + /// + /// + /// + public static async Task UpdateAllPluginsAsync(IEnumerable resultsForUpdate, bool restart) + { + var anyPluginSuccess = false; + await Task.WhenAll(resultsForUpdate.Select(async plugin => + { + var downloadToFilePath = Path.Combine(Path.GetTempPath(), $"{plugin.Name}-{plugin.NewVersion}.zip"); + + try + { + using var cts = new CancellationTokenSource(); + + await DownloadFileAsync( + $"{API.GetTranslation("DownloadingPlugin")} {plugin.PluginNewUserPlugin.Name}", + plugin.PluginNewUserPlugin.UrlDownload, downloadToFilePath, cts); + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!await API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, downloadToFilePath)) + { + return; + } + + anyPluginSuccess = true; + } + catch (Exception e) + { + API.LogException(ClassName, "Failed to update plugin", e); + API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin")); + } + })); + + if (!anyPluginSuccess) return; + + if (restart) + { + API.RestartApp(); + } + else + { + API.ShowMsg( + API.GetTranslation("updatebtn"), + API.GetTranslation("PluginsUpdateSuccessNoRestart")); + } + } + + /// + /// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation. + /// + /// The title for the progress box. + /// The URL to download from. + /// The local file path to save to. + /// Cancellation token source for cancelling the download. + /// Whether to delete the file if it already exists. + /// Whether to show a progress box during download. + /// A Task representing the asynchronous download operation. + private static async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true) + { + if (deleteFile && File.Exists(filePath)) + File.Delete(filePath); + + if (showProgress) + { + var exceptionHappened = false; + await API.ShowProgressBoxAsync(progressBoxTitle, + async (reportProgress) => + { + if (reportProgress == null) + { + // when reportProgress is null, it means there is exception with the progress box + // so we record it with exceptionHappened and return so that progress box will close instantly + exceptionHappened = true; + return; + } + else + { + await API.HttpDownloadAsync(downloadUrl, filePath, reportProgress, cts.Token).ConfigureAwait(false); + } + }, cts.Cancel); + + // if exception happened while downloading and user does not cancel downloading, + // we need to redownload the plugin + if (exceptionHappened && (!cts.IsCancellationRequested)) + await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false); + } + else + { + await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false); + } + } + + /// + /// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author). + /// + /// The URL to check. + /// True if the source is known, otherwise false. + private static bool InstallSourceKnown(string url) + { + if (string.IsNullOrEmpty(url)) + return false; + + var pieces = url.Split('/'); + + if (pieces.Length < 4) + return false; + + var author = pieces[3]; + var acceptedHost = "github.com"; + var acceptedSource = "https://github.com"; + var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author); + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost) + return false; + + return API.GetAllPlugins().Any(x => + !string.IsNullOrEmpty(x.Metadata.Website) && + x.Metadata.Website.StartsWith(constructedUrlPart) + ); + } +} + +public record PluginUpdateInfo +{ + public string ID { get; init; } + public string Name { get; init; } + public string Author { get; init; } + public string CurrentVersion { get; init; } + public string NewVersion { get; init; } + public string IcoPath { get; init; } + public PluginMetadata PluginExistingMetadata { get; init; } + public UserPlugin PluginNewUserPlugin { get; init; } +} diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 5c4eaa1da..a4ab8de08 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1,43 +1,53 @@ -using Flow.Launcher.Core.ExternalPlugins; 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.DialogJump; 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 IRemovable = Flow.Launcher.Core.Storage.IRemovable; +using ISavable = Flow.Launcher.Plugin.ISavable; namespace Flow.Launcher.Core.Plugin { /// - /// The entry for managing Flow Launcher plugins + /// Class for co-ordinating and managing all plugin lifecycle. /// public static class PluginManager { - private static IEnumerable _contextMenuPlugins; + private static readonly string ClassName = nameof(PluginManager); public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); public static readonly Dictionary NonGlobalPlugins = new(); - public static IPublicAPI API { private set; get; } + // 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 static PluginsSettings Settings; - private static List _metadatas; - private static List _modifiedPlugins = new List(); + private static readonly ConcurrentBag ModifiedPlugins = new(); + + private static IEnumerable _contextMenuPlugins; + private static IEnumerable _homePlugins; + private static IEnumerable _resultUpdatePlugin; + private static IEnumerable _translationPlugins; + + private static readonly List _dialogJumpExplorerPlugins = new(); + private static readonly List _dialogJumpDialogPlugins = new(); /// /// Directories that will hold Flow Launcher plugin directory /// - private static readonly string[] Directories = + public static readonly string[] Directories = { Constant.PreinstalledDirectory, DataLocation.PluginsDirectory }; @@ -56,18 +66,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) { @@ -79,6 +105,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() @@ -148,43 +178,105 @@ namespace Flow.Launcher.Core.Plugin /// public static void LoadPlugins(PluginsSettings settings) { - _metadatas = PluginConfig.Parse(Directories); + var metadatas = PluginConfig.Parse(Directories); Settings = settings; - Settings.UpdatePluginSettings(_metadatas); - AllPlugins = PluginsLoader.Plugins(_metadatas, 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); + + // Initialize plugin enumerable after all plugins are initialized + _contextMenuPlugins = GetPluginsForInterface(); + _homePlugins = GetPluginsForInterface(); + _resultUpdatePlugin = GetPluginsForInterface(); + _translationPlugins = GetPluginsForInterface(); + + // Initialize Dialog Jump plugin pairs + foreach (var pair in GetPluginsForInterface()) + { + _dialogJumpExplorerPlugins.Add(new DialogJumpExplorerPair + { + Plugin = (IDialogJumpExplorer)pair.Plugin, + Metadata = pair.Metadata + }); + } + foreach (var pair in GetPluginsForInterface()) + { + _dialogJumpDialogPlugins.Add(new DialogJumpDialogPair + { + Plugin = (IDialogJumpDialog)pair.Plugin, + Metadata = pair.Metadata + }); + } + } + + 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); + } + } } /// /// Call initialize for all plugins /// /// return the list of failed to init plugins or null for none - public static async Task InitializePluginsAsync(IPublicAPI api) + public static async Task InitializePluginsAsync() { - API = api; var failedPlugins = new ConcurrentQueue(); var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate { 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(); foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin @@ -203,16 +295,13 @@ namespace Flow.Launcher.Core.Plugin } } - InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface()); - InternationalizationManager.Instance.ChangeLanguage(InternationalizationManager.Instance.Settings.Language); - if (failedPlugins.Any()) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); API.ShowMsg( - InternationalizationManager.Instance.GetTranslation("failedToInitializePluginsTitle"), + API.GetTranslation("failedToInitializePluginsTitle"), string.Format( - InternationalizationManager.Instance.GetTranslation("failedToInitializePluginsMessage"), + API.GetTranslation("failedToInitializePluginsMessage"), failed ), "", @@ -221,22 +310,36 @@ namespace Flow.Launcher.Core.Plugin } } - public static ICollection ValidPluginsForQuery(Query query) + public static ICollection ValidPluginsForQuery(Query query, bool dialogJump) { if (query is null) return Array.Empty(); - if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword)) - return GlobalPlugins; + if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) + { + if (dialogJump) + return GlobalPlugins.Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID)).ToList(); + else + return GlobalPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + if (dialogJump && plugin.Plugin is not IAsyncDialogJump) + return Array.Empty(); + + if (API.PluginModified(plugin.Metadata.ID)) + return Array.Empty(); - var plugin = NonGlobalPlugins[query.ActionKeyword]; return new List { plugin }; } + public static ICollection ValidPluginsForHomeQuery() + { + return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) { var results = new List(); @@ -244,7 +347,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(); @@ -268,7 +371,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, @@ -281,7 +384,67 @@ namespace Flow.Launcher.Core.Plugin return results; } - public static void UpdatePluginMetadata(List results, PluginMetadata metadata, Query query) + 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 async Task> QueryDialogJumpForPluginAsync(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 ((IAsyncDialogJump)pair.Plugin).QueryDialogJumpAsync(query, 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 Dialog Jump for plugin: {metadata.Name}", e); + return null; + } + return results; + } + + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) { @@ -306,16 +469,26 @@ namespace Flow.Launcher.Core.Plugin return AllPlugins.FirstOrDefault(o => o.Metadata.ID == id); } - public static IEnumerable GetPluginsForInterface() where T : IFeatures + private static IEnumerable GetPluginsForInterface() where T : IFeatures { // Handle scenario where this is called before all plugins are instantiated, e.g. language change on startup return AllPlugins?.Where(p => p.Plugin is T) ?? Array.Empty(); } + public static IList GetResultUpdatePlugin() + { + return _resultUpdatePlugin.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + + public static IList GetTranslationPlugins() + { + return _translationPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + public static List GetContextMenusForPlugin(Result result) { var results = new List(); - var pluginPair = _contextMenuPlugins.FirstOrDefault(o => o.Metadata.ID == result.PluginID); + var pluginPair = _contextMenuPlugins.Where(p => !PluginModified(p.Metadata.ID)).FirstOrDefault(o => o.Metadata.ID == result.PluginID); if (pluginPair != null) { var plugin = (IContextMenu)pluginPair.Plugin; @@ -332,8 +505,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); } } @@ -341,12 +514,27 @@ namespace Flow.Launcher.Core.Plugin return results; } + public static bool IsHomePlugin(string id) + { + return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id); + } + + public static IList GetDialogJumpExplorers() + { + return _dialogJumpExplorerPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + + public static IList GetDialogJumpDialogs() + { + return _dialogJumpDialogPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + 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); } /// @@ -365,7 +553,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; + } } /// @@ -386,16 +583,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; } } @@ -420,55 +616,62 @@ namespace Flow.Launcher.Core.Plugin private static bool SameOrLesserPluginVersionExists(string metadataPath) { var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + + if (!Version.TryParse(newMetadata.Version, out var newVersion)) + return true; // If version is not valid, we assume it is lesser than any existing version + return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID - && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); + && Version.TryParse(x.Metadata.Version, out var version) + && newVersion <= version); } #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, removeSettings:false, checkModified:false); - _modifiedPlugins.Add(existingVersion.ID); + if (PluginModified(existingVersion.ID)) + { + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), existingVersion.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; + } + + var installSuccess = InstallPlugin(newVersion, zipFilePath, checkModified: false); + if (!installSuccess) return false; + + var uninstallSuccess = await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false); + if (!uninstallSuccess) return false; + + ModifiedPlugins.Add(existingVersion.ID); + return true; } - /// - /// 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) + public static bool InstallPlugin(UserPlugin plugin, string zipFilePath) { - InstallPlugin(plugin, zipFilePath, checkModified: true); + return InstallPlugin(plugin, zipFilePath, checkModified: true); } - /// - /// Uninstall a plugin. - /// - public static void UninstallPlugin(PluginMetadata plugin, bool removeSettings = true) + public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) { - UninstallPlugin(plugin, removeSettings, true); + return await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true); } #endregion #region Internal functions - internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) + internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { - // Distinguish exception from installing same or less version - throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin)); + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; } // Unzip plugin files to temp folder @@ -486,31 +689,35 @@ namespace Flow.Launcher.Core.Plugin if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) { - throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist"); + API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), + string.Format(API.GetTranslation("fileNotFoundMessage"), pluginFolderPath)); + return false; } if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) { - throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}"); + API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name), + API.GetTranslation("pluginExistAlreadyMessage")); + return false; } 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) @@ -519,27 +726,92 @@ namespace Flow.Launcher.Core.Plugin var newPluginPath = Path.Combine(installDirectory, folderName); - FilesFolders.CopyAll(pluginFolderPath, newPluginPath, MessageBoxEx.Show); + FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => API.ShowMsgBox(s)); - Directory.Delete(tempFolderPluginPath, true); + try + { + if (Directory.Exists(tempFolderPluginPath)) + Directory.Delete(tempFolderPluginPath, true); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); + } if (checkModified) { - _modifiedPlugins.Add(plugin.ID); + ModifiedPlugins.Add(plugin.ID); } + + return true; } - internal static void UninstallPlugin(PluginMetadata plugin, bool removeSettings, 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"); + API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name), + API.GetTranslation("pluginModifiedAlreadyMessage")); + return false; } - if (removeSettings) + if (removePluginSettings || removePluginFromSettings) { - Settings.Plugins.Remove(plugin.ID); + // 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) + { + // For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances + if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable) + { + removable.RemovePluginSettings(plugin.AssemblyName); + removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath); + } + + try + { + 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.ShowMsgError(API.GetTranslation("failedToRemovePluginSettingsTitle"), + string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); + } + } + + if (removePluginFromSettings) + { + 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.ShowMsgError(API.GetTranslation("failedToRemovePluginCacheTitle"), + string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); + } + Settings.RemovePluginSettings(plugin.ID); AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); + GlobalPlugins.RemoveWhere(p => p.Metadata.ID == plugin.ID); + var keysToRemove = NonGlobalPlugins.Where(p => p.Value.Metadata.ID == plugin.ID).Select(p => p.Key).ToList(); + foreach (var key in keysToRemove) + { + NonGlobalPlugins.Remove(key); + } } // Marked for deletion. Will be deleted on next start up @@ -547,8 +819,10 @@ namespace Flow.Launcher.Core.Plugin if (checkModified) { - _modifiedPlugins.Add(plugin.ID); + ModifiedPlugins.Add(plugin.ID); } + + return true; } #endregion diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 7973c66ba..e9e5ee367 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -4,18 +4,24 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins.Environments; #pragma warning disable IDE0005 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); @@ -49,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(); @@ -58,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; @@ -73,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 @@ -111,41 +118,45 @@ 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 ") - + "errored and cannot be loaded:"; + var errorMessage = erroredPlugins.Count > 1 ? + API.GetTranslation("pluginsHaveErrored") : + API.GetTranslation("pluginHasErrored"); - _ = Task.Run(() => - { - MessageBoxEx.Show($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + - $"{errorPluginString}{Environment.NewLine}{Environment.NewLine}" + - $"Please refer to the logs for more information", "", - MessageBoxButton.OK, MessageBoxImage.Warning); - }); + API.ShowMsgError($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + + $"{errorPluginString}{Environment.NewLine}{Environment.NewLine}" + + API.GetTranslation("referToLogs")); } 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/AvailableLanguages.cs b/Flow.Launcher.Core/Resource/AvailableLanguages.cs index ecaecf646..5534ea172 100644 --- a/Flow.Launcher.Core/Resource/AvailableLanguages.cs +++ b/Flow.Launcher.Core/Resource/AvailableLanguages.cs @@ -17,6 +17,7 @@ namespace Flow.Launcher.Core.Resource public static Language German = new Language("de", "Deutsch"); public static Language Korean = new Language("ko", "한국어"); public static Language Serbian = new Language("sr", "Srpski"); + public static Language Serbian_Cyrillic = new Language("sr-Cyrl-RS", "Српски"); public static Language Portuguese_Portugal = new Language("pt-pt", "Português"); public static Language Portuguese_Brazil = new Language("pt-br", "Português (Brasil)"); public static Language Spanish = new Language("es", "Spanish"); @@ -47,6 +48,7 @@ namespace Flow.Launcher.Core.Resource German, Korean, Serbian, + Serbian_Cyrillic, Portuguese_Portugal, Portuguese_Brazil, Spanish, @@ -79,7 +81,8 @@ namespace Flow.Launcher.Core.Resource "da" => "System", "de" => "System", "ko" => "시스템", - "sr" => "Систем", + "sr" => "Sistem", + "sr-Cyrl-RS" => "Систем", "pt-pt" => "Sistema", "pt-br" => "Sistema", "es" => "Sistema", diff --git a/Flow.Launcher.Core/Resource/Internationalization.cs b/Flow.Launcher.Core/Resource/Internationalization.cs index ef38e8be0..983f8b234 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -1,43 +1,48 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; 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; -using System.Threading.Tasks; namespace Flow.Launcher.Core.Resource { - public class Internationalization + public class Internationalization : IDisposable { - public Settings Settings { get; set; } + 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 List _languageDirectories = new List(); - private readonly List _oldResources = new List(); - private readonly string SystemLanguageCode; + private readonly Settings _settings; + private readonly List _languageDirectories = []; + private readonly List _oldResources = []; + private static string SystemLanguageCode; + private readonly SemaphoreSlim _langChangeLock = new(1, 1); - public Internationalization() + public Internationalization(Settings settings) { - AddFlowLauncherLanguageDirectory(); - SystemLanguageCode = GetSystemLanguageCodeAtStartup(); + _settings = settings; } - private void AddFlowLauncherLanguageDirectory() - { - var directory = Path.Combine(Constant.ProgramDirectory, Folder); - _languageDirectories.Add(directory); - } + #region Initialization - private static string GetSystemLanguageCodeAtStartup() + /// + /// Initialize the system language code based on the current culture. + /// + public static void InitSystemLanguageCode() { var availableLanguages = AvailableLanguages.GetAvailableLanguages(); @@ -58,31 +63,71 @@ namespace Flow.Launcher.Core.Resource string.Equals(languageCode, threeLetterCode, StringComparison.OrdinalIgnoreCase) || string.Equals(languageCode, fullName, StringComparison.OrdinalIgnoreCase)) { - return languageCode; + SystemLanguageCode = languageCode; } } - return DefaultLanguageCode; + SystemLanguageCode = DefaultLanguageCode; } - internal void AddPluginLanguageDirectories(IEnumerable plugins) + /// + /// Initialize language. Will change app language and plugin language based on settings. + /// + public async Task InitializeLanguageAsync() { - foreach (var plugin in plugins) + // Get actual language + var languageCode = _settings.Language; + if (languageCode == Constant.SystemLanguageCode) { - var location = Assembly.GetAssembly(plugin.Plugin.GetType()).Location; - var dir = Path.GetDirectoryName(location); - if (dir != null) - { - var pluginThemeDirectory = Path.Combine(dir, Folder); - _languageDirectories.Add(pluginThemeDirectory); - } - else - { - Log.Error($"|Internationalization.AddPluginLanguageDirectories|Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); - } + languageCode = SystemLanguageCode; } + // Get language by language code and change language + var language = GetLanguageByLanguageCode(languageCode); + + // Add Flow Launcher language directory + AddFlowLauncherLanguageDirectory(); + + // Add plugin language directories first so that we can load language files from plugins + AddPluginLanguageDirectories(); + + // Load default language resources LoadDefaultLanguage(); + + // Change language + await ChangeLanguageAsync(language, false); + } + + private void AddFlowLauncherLanguageDirectory() + { + // Check if Flow Launcher language directory exists + var directory = Path.Combine(Constant.ProgramDirectory, Folder); + if (!Directory.Exists(directory)) + { + API.LogError(ClassName, $"Flow Launcher language directory can't be found <{directory}>"); + return; + } + + _languageDirectories.Add(directory); + } + + private void AddPluginLanguageDirectories() + { + foreach (var pluginsDir in PluginManager.Directories) + { + if (!Directory.Exists(pluginsDir)) continue; + + // Enumerate all top directories in the plugin directory + foreach (var dir in Directory.GetDirectories(pluginsDir)) + { + // Check if the directory contains a language folder + var pluginLanguageDir = Path.Combine(dir, Folder); + if (!Directory.Exists(pluginLanguageDir)) continue; + + // Check if the language directory contains default language file since it will be checked later + _languageDirectories.Add(pluginLanguageDir); + } + } } private void LoadDefaultLanguage() @@ -94,6 +139,14 @@ namespace Flow.Launcher.Core.Resource _oldResources.Clear(); } + #endregion + + #region Change Language + + /// + /// Change language during runtime. Will change app language and plugin language & save settings. + /// + /// public void ChangeLanguage(string languageCode) { languageCode = languageCode.NonNull(); @@ -108,16 +161,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); + var language = AvailableLanguages.GetAvailableLanguages(). + FirstOrDefault(o => o.LanguageCode.Equals(languageCode, StringComparison.OrdinalIgnoreCase)); 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 @@ -126,33 +184,67 @@ namespace Flow.Launcher.Core.Resource } } - private void ChangeLanguage(Language language, bool isSystem) + private async Task ChangeLanguageAsync(Language language, bool updateMetadata = true) { - language = language.NonNull(); + await _langChangeLock.WaitAsync(); - RemoveOldLanguageFiles(); - if (language != AvailableLanguages.English) + try { - LoadLanguage(language); + // Remove old language files and load language + RemoveOldLanguageFiles(); + if (language != AvailableLanguages.English) + { + LoadLanguage(language); + } + + // Change culture info + ChangeCultureInfo(language.LanguageCode); + + if (updateMetadata) + { + // Raise event for plugins after culture is set + await Task.Run(UpdatePluginMetadataTranslations); + } } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to change language to <{language.LanguageCode}>", e); + } + finally + { + _langChangeLock.Release(); + } + } + + public static void ChangeCultureInfo(string languageCode) + { // 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(() => + CultureInfo currentCulture; + try { - UpdatePluginMetadataTranslations(); - }); + currentCulture = CultureInfo.CreateSpecificCulture(languageCode); + } + catch (CultureNotFoundException) + { + currentCulture = CultureInfo.CreateSpecificCulture(SystemLanguageCode); + } + CultureInfo.CurrentCulture = currentCulture; + CultureInfo.CurrentUICulture = currentCulture; + var thread = Thread.CurrentThread; + thread.CurrentCulture = currentCulture; + thread.CurrentUICulture = currentCulture; } + #endregion + + #region Prompt Pinyin + public bool PromptShouldUsePinyin(string languageCodeToSet) { var languageToSet = GetLanguageByLanguageCode(languageCodeToSet); - if (Settings.ShouldUsePinyin) + if (_settings.ShouldUsePinyin) return false; if (languageToSet != AvailableLanguages.Chinese && languageToSet != AvailableLanguages.Chinese_TW) @@ -160,14 +252,18 @@ namespace Flow.Launcher.Core.Resource // No other languages should show the following text so just make it hard-coded // "Do you want to search with pinyin?" - string text = languageToSet == AvailableLanguages.Chinese ? "是否启用拼音搜索?" : "是否啓用拼音搜索?" ; + string text = languageToSet == AvailableLanguages.Chinese ? "是否启用拼音搜索?" : "是否啓用拼音搜索?"; - if (MessageBoxEx.Show(text, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + if (API.ShowMsgBox(text, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) return false; return true; } + #endregion + + #region Language Resources Management + private void RemoveOldLanguageFiles() { var dicts = Application.Current.Resources.MergedDictionaries; @@ -175,6 +271,7 @@ namespace Flow.Launcher.Core.Resource { dicts.Remove(r); } + _oldResources.Clear(); } private void LoadLanguage(Language language) @@ -203,66 +300,26 @@ namespace Flow.Launcher.Core.Resource } } - public List LoadAvailableLanguages() - { - var list = AvailableLanguages.GetAvailableLanguages(); - list.Insert(0, new Language(Constant.SystemLanguageCode, AvailableLanguages.GetSystemTranslation(SystemLanguageCode))); - return list; - } - - public string GetTranslation(string key) - { - var translation = Application.Current.TryFindResource(key); - if (translation is string) - { - return translation.ToString(); - } - else - { - Log.Error($"|Internationalization.GetTranslation|No Translation for key {key}"); - return $"No Translation for key {key}"; - } - } - - private void UpdatePluginMetadataTranslations() - { - foreach (var p in PluginManager.GetPluginsForInterface()) - { - var pluginI18N = p.Plugin as IPluginI18n; - if (pluginI18N == null) return; - try - { - p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); - p.Metadata.Description = pluginI18N.GetTranslatedPluginDescription(); - pluginI18N.OnCultureInfoChanged(CultureInfo.CurrentCulture); - } - catch (Exception e) - { - Log.Exception($"|Internationalization.UpdatePluginMetadataTranslations|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; } } @@ -272,5 +329,69 @@ namespace Flow.Launcher.Core.Resource return string.Empty; } } + + #endregion + + #region Available Languages + + public List LoadAvailableLanguages() + { + var list = AvailableLanguages.GetAvailableLanguages(); + list.Insert(0, new Language(Constant.SystemLanguageCode, AvailableLanguages.GetSystemTranslation(SystemLanguageCode))); + return list; + } + + #endregion + + #region Get Translations + + public static string GetTranslation(string key) + { + var translation = Application.Current.TryFindResource(key); + if (translation is string) + { + return translation.ToString(); + } + else + { + API.LogError(ClassName, $"No Translation for key {key}"); + return $"No Translation for key {key}"; + } + } + + #endregion + + #region Update Metadata + + public static void UpdatePluginMetadataTranslations() + { + // Update plugin metadata name & description + foreach (var p in PluginManager.GetTranslationPlugins()) + { + if (p.Plugin is not IPluginI18n pluginI18N) return; + try + { + p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle(); + p.Metadata.Description = pluginI18N.GetTranslatedPluginDescription(); + pluginI18N.OnCultureInfoChanged(CultureInfo.CurrentCulture); + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed for <{p.Metadata.Name}>", e); + } + } + } + + #endregion + + #region IDisposable + + public void Dispose() + { + RemoveOldLanguageFiles(); + _langChangeLock.Dispose(); + } + + #endregion } } diff --git a/Flow.Launcher.Core/Resource/InternationalizationManager.cs b/Flow.Launcher.Core/Resource/InternationalizationManager.cs deleted file mode 100644 index 3d87626e6..000000000 --- a/Flow.Launcher.Core/Resource/InternationalizationManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Flow.Launcher.Core.Resource -{ - public static class InternationalizationManager - { - private static Internationalization instance; - private static object syncObject = new object(); - - public static Internationalization Instance - { - get - { - if (instance == null) - { - lock (syncObject) - { - if (instance == null) - { - instance = new Internationalization(); - } - } - } - return instance; - } - } - } -} \ No newline at end of file diff --git a/Flow.Launcher.Core/Resource/LocalizationConverter.cs b/Flow.Launcher.Core/Resource/LocalizationConverter.cs deleted file mode 100644 index 81600e023..000000000 --- a/Flow.Launcher.Core/Resource/LocalizationConverter.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; -using System.Reflection; -using System.Windows.Data; - -namespace Flow.Launcher.Core.Resource -{ - public class LocalizationConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (targetType == typeof(string) && value != null) - { - FieldInfo fi = value.GetType().GetField(value.ToString()); - if (fi != null) - { - string localizedDescription = string.Empty; - var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false); - if ((attributes.Length > 0) && (!String.IsNullOrEmpty(attributes[0].Description))) - { - localizedDescription = attributes[0].Description; - } - - return (!String.IsNullOrEmpty(localizedDescription)) ? localizedDescription : value.ToString(); - } - } - - return string.Empty; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} 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 2c2feeae1..a6e8dc6bf 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -3,62 +3,86 @@ 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 Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; -using Flow.Launcher.Infrastructure.UserSettings; using System.Windows.Shell; +using System.Windows.Threading; +using Flow.Launcher.Infrastructure; +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:"; private const int ShadowExtraMargin = 32; - private readonly List _themeDirectories = new List(); + private readonly IPublicAPI _api; + private readonly Settings _settings; + private readonly List _themeDirectories = new(); private ResourceDictionary _oldResource; private string _oldTheme; - public Settings Settings { get; set; } 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 bool BlurEnabled { get; set; } + private Thickness _themeResizeBorderThickness; - private double mainWindowWidth; + #endregion - public Theme() + #region Constructor + + public Theme(IPublicAPI publicAPI, Settings settings) { + _api = publicAPI; + _settings = settings; + _themeDirectories.Add(DirectoryPath); _themeDirectories.Add(UserDirectoryPath); 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))) @@ -69,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) - { - MessageBoxEx.Show(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) - { - MessageBoxEx.Show(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); @@ -147,36 +257,34 @@ 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); 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); + var fontFamily = new FontFamily(_settings.QueryBoxFont); + var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); + 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 && @@ -184,10 +292,10 @@ namespace Flow.Launcher.Core.Resource dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && dict["ItemHotkeySelectedStyle"] is Style resultHotkeyItemSelectedStyle) { - Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(Settings.ResultFont)); - Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(Settings.ResultFontStyle)); - Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(Settings.ResultFontWeight)); - Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(Settings.ResultFontStretch)); + Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(_settings.ResultFont)); + Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultFontStyle)); + Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultFontWeight)); + Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultFontStretch)); Setter[] setters = { fontFamily, fontStyle, fontWeight, fontStretch }; Array.ForEach( @@ -199,43 +307,27 @@ namespace Flow.Launcher.Core.Resource dict["ItemSubTitleStyle"] is Style resultSubItemStyle && dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) { - Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(Settings.ResultSubFont)); - Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(Settings.ResultSubFontStyle)); - Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(Settings.ResultSubFontWeight)); - Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(Settings.ResultSubFontStretch)); + Setter fontFamily = new Setter(TextBlock.FontFamilyProperty, new FontFamily(_settings.ResultSubFont)); + Setter fontStyle = new Setter(TextBlock.FontStyleProperty, FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultSubFontStyle)); + Setter fontWeight = new Setter(TextBlock.FontWeightProperty, FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultSubFontWeight)); + Setter fontStretch = new Setter(TextBlock.FontStretchProperty, FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultSubFontStretch)); 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; + var width = _settings.WindowSize; + 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) @@ -257,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()); } } @@ -286,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(); @@ -294,7 +473,7 @@ namespace Flow.Launcher.Core.Resource var effectSetter = new Setter { - Property = Border.EffectProperty, + Property = UIElement.EffectProperty, Value = new DropShadowEffect { Opacity = 0.3, @@ -304,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); @@ -340,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( @@ -363,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 71f9acaa5..000000000 --- a/Flow.Launcher.Core/Resource/ThemeManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Flow.Launcher.Core.Resource -{ - public class ThemeManager - { - private static Theme instance; - private static object syncObject = new object(); - - public static Theme Instance - { - get - { - if (instance == null) - { - lock (syncObject) - { - if (instance == null) - { - instance = new Theme(); - } - } - } - return instance; - } - } - } -} diff --git a/Flow.Launcher.Core/Resource/TranslationConverter.cs b/Flow.Launcher.Core/Resource/TranslationConverter.cs deleted file mode 100644 index ebab99e5b..000000000 --- a/Flow.Launcher.Core/Resource/TranslationConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace Flow.Launcher.Core.Resource -{ - public class TranslationConverter : IValueConverter - { - 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); - } - - public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.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 b92d86568..45275696c 100644 --- a/Flow.Launcher.Core/Updater.cs +++ b/Flow.Launcher.Core/Updater.cs @@ -4,62 +4,67 @@ 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 { public class Updater { - public string GitHubRepository { get; } + public string GitHubRepository { get; init; } - public Updater(string gitHubRepository) + private static readonly string ClassName = nameof(Updater); + + private readonly IPublicAPI _api; + + public Updater(IPublicAPI publicAPI, string gitHubRepository) { + _api = publicAPI; GitHubRepository = gitHubRepository; } private SemaphoreSlim UpdateLock { get; } = new SemaphoreSlim(1); - public async Task UpdateAppAsync(IPublicAPI api, bool silentUpdate = true) + public async Task UpdateAppAsync(bool silentUpdate = true) { await UpdateLock.WaitAsync().ConfigureAwait(false); try { if (!silentUpdate) - api.ShowMsg(api.GetTranslation("pleaseWait"), - api.GetTranslation("update_flowlauncher_update_check")); + _api.ShowMsg(_api.GetTranslation("pleaseWait"), + _api.GetTranslation("update_flowlauncher_update_check")); using var updateManager = await GitHubUpdateManagerAsync(GitHubRepository).ConfigureAwait(false); // UpdateApp CheckForUpdate will return value only if the app is squirrel installed var newUpdateInfo = await updateManager.CheckForUpdate().NonNull().ConfigureAwait(false); - var newReleaseVersion = Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString()); - var currentVersion = Version.Parse(Constant.Version); + var newReleaseVersion = + SemanticVersioning.Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString()); + var currentVersion = SemanticVersioning.Version.Parse(Constant.Version); - Log.Info($"|Updater.UpdateApp|Future Release <{newUpdateInfo.FutureReleaseEntry.Formatted()}>"); + _api.LogInfo(ClassName, $"Future Release <{Formatted(newUpdateInfo.FutureReleaseEntry)}>"); if (newReleaseVersion <= currentVersion) { if (!silentUpdate) - MessageBoxEx.Show(api.GetTranslation("update_flowlauncher_already_on_latest")); + _api.ShowMsgBox(_api.GetTranslation("update_flowlauncher_already_on_latest")); return; } if (!silentUpdate) - api.ShowMsg(api.GetTranslation("update_flowlauncher_update_found"), - api.GetTranslation("update_flowlauncher_updating")); + _api.ShowMsg(_api.GetTranslation("update_flowlauncher_update_found"), + _api.GetTranslation("update_flowlauncher_updating")); await updateManager.DownloadReleases(newUpdateInfo.ReleasesToApply).ConfigureAwait(false); @@ -67,10 +72,13 @@ namespace Flow.Launcher.Core if (DataLocation.PortableDataLocationInUse()) { - var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion.ToString()}\\{DataLocation.PortableFolderName}"; - FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination, MessageBoxEx.Show); - if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, MessageBoxEx.Show)) - MessageBoxEx.Show(string.Format(api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"), + 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"), DataLocation.PortableDataPath, targetDestination)); } @@ -81,23 +89,30 @@ namespace Flow.Launcher.Core var newVersionTips = NewVersionTips(newReleaseVersion.ToString()); - Log.Info($"|Updater.UpdateApp|Update success:{newVersionTips}"); + _api.LogInfo(ClassName, $"Update success:{newVersionTips}"); - if (MessageBoxEx.Show(newVersionTips, api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) + if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"), + MessageBoxButton.YesNo) == MessageBoxResult.Yes) { UpdateManager.RestartApp(Constant.ApplicationFileName); } } 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"), - api.GetTranslation("update_flowlauncher_check_connection")); + _api.ShowMsgError(_api.GetTranslation("update_flowlauncher_fail"), + _api.GetTranslation("update_flowlauncher_check_connection")); } finally { @@ -108,32 +123,26 @@ namespace Flow.Launcher.Core [UsedImplicitly] private class GithubRelease { - [JsonPropertyName("prerelease")] - public bool Prerelease { get; [UsedImplicitly] set; } + [JsonPropertyName("prerelease")] public bool Prerelease { get; [UsedImplicitly] set; } - [JsonPropertyName("published_at")] - public DateTime PublishedAt { get; [UsedImplicitly] set; } + [JsonPropertyName("published_at")] public DateTime PublishedAt { get; [UsedImplicitly] set; } - [JsonPropertyName("html_url")] - public string HtmlUrl { get; [UsedImplicitly] set; } + [JsonPropertyName("html_url")] public string HtmlUrl { get; [UsedImplicitly] set; } } // 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/"); - var client = new WebClient - { - Proxy = Http.WebProxy - }; + var client = new WebClient { Proxy = Http.WebProxy }; var downloader = new FileDownloader(client); var manager = new UpdateManager(latestUrl, urlDownloader: downloader); @@ -141,12 +150,18 @@ 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.Core/packages.lock.json b/Flow.Launcher.Core/packages.lock.json new file mode 100644 index 000000000..b7a00d94d --- /dev/null +++ b/Flow.Launcher.Core/packages.lock.json @@ -0,0 +1,277 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "Droplex": { + "type": "Direct", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "wutfIus/Ufw/9TDsp86R1ycnIH+wWrj4UhcmrzAHWjsdyC2iM07WEQ9+APTB7pQynsDnYH1r2i58XgAJ3lxUXA==", + "dependencies": { + "YamlDotNet": "9.1.0" + } + }, + "FSharp.Core": { + "type": "Direct", + "requested": "[9.0.303, )", + "resolved": "9.0.303", + "contentHash": "6JlV8aD8qQvcmfoe/PMOxCHXc0uX4lR23u0fAyQtnVQxYULLoTZgwgZHSnRcuUHOvS3wULFWcwdnP1iwslH60g==" + }, + "Meziantou.Framework.Win32.Jobs": { + "type": "Direct", + "requested": "[3.4.4, )", + "resolved": "3.4.4", + "contentHash": "AivBzH5wM1NHBLehclim+o37SmireP7JxCRUoTilsc/h7LH9+YCPjb6Ig6y0khnQhFcO1P8RHYw4oiR15TGHUg==" + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Direct", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g==" + }, + "SemanticVersioning": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "RR+8GbPQ/gjDqov/1QN1OPoUlbUruNwcL3WjWCeLw+MY7+od/ENhnkYxCfAC6rQLIu3QifaJt3kPYyP3RumqMQ==" + }, + "squirrel.windows": { + "type": "Direct", + "requested": "[1.5.2, )", + "resolved": "1.5.2", + "contentHash": "89Y/CFxWm7SEOjvuV2stVa8p+SNM9GOLk4tUNm2nUF792nfkimAgwRA/umVsdyd/OXBH8byXSh4V1qck88ZAyQ==", + "dependencies": { + "DeltaCompressionDotNet": "[1.0.0, 2.0.0)", + "Mono.Cecil": "0.9.6.1", + "Splat": "1.6.2" + } + }, + "StreamJsonRpc": { + "type": "Direct", + "requested": "[2.22.11, )", + "resolved": "2.22.11", + "contentHash": "TQcqBFswLNpdSJANjhxZmIIe0Yl0kGqzjZ+uHLdhrkxntofvNu6C53XPEEYQ3Wkj8AorKumkuv/VMvTH4BHOZw==", + "dependencies": { + "MessagePack": "2.5.192", + "Microsoft.VisualStudio.Threading.Only": "17.13.61", + "Microsoft.VisualStudio.Validation": "17.8.8", + "Nerdbank.Streams": "2.12.87", + "Newtonsoft.Json": "13.0.3", + "System.IO.Pipelines": "8.0.0" + } + }, + "Ben.Demystifier": { + "type": "Transitive", + "resolved": "0.4.1", + "contentHash": "axFeEMfmEORy3ipAzOXG/lE+KcNptRbei3F0C4kQCdeiQtW+qJW90K5iIovITGrdLt8AjhNCwk5qLSX9/rFpoA==", + "dependencies": { + "System.Reflection.Metadata": "5.0.0" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.5.4", + "contentHash": "1QroTY1PVCZOSG9FnkkCrmCKk/+bZCgI/YXq376HnYwUDJ4Ho0EaV4YaA/5v5WYLnwIwIO7RZkdWbg9pxIpueQ==" + }, + "CommunityToolkit.Mvvm": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw==" + }, + "DeltaCompressionDotNet": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "nwbZAYd+DblXAIzlnwDSnl0CiCm8jWLfHSYnoN4wYhtIav6AegB3+T/vKzLbU2IZlPB8Bvl8U3NXpx3eaz+N5w==" + }, + "InputSimulator": { + "type": "Transitive", + "resolved": "1.0.4", + "contentHash": "D0LvRCPQMX6/FJHBjng+RO+wRDuHTJrfo7IAc7rmkPvRqchdVGJWg3y70peOtDy3OLNK+HSOwVkH4GiuLnkKgA==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.2", + "contentHash": "0X56ZRizuHdrnPpgXjWV7f2tQO1FlQg5O1967OGKnI/4ZRNOK642J8L7brM1nYvrxTTU5TP1yRyXLRLaXLPQ8A==" + }, + "MemoryPack": { + "type": "Transitive", + "resolved": "1.21.4", + "contentHash": "wy3JTBNBsO8LfQcBvvYsWr3lm2Oakolrfu0UQ3oSJSEiD+7ye0GUhYTaXuYYBowqsXBXWD9gf2218ae0JRiYVQ==", + "dependencies": { + "MemoryPack.Core": "1.21.4", + "MemoryPack.Generator": "1.21.4" + } + }, + "MemoryPack.Core": { + "type": "Transitive", + "resolved": "1.21.4", + "contentHash": "6RszGorZ0ejNmp37ZcboPBMvvPCuNW2jlrdQfcs/lMzE5b3pmPF6hsm/laDc34hRlbAST1ZxaX/DvYu2DF5sBQ==" + }, + "MemoryPack.Generator": { + "type": "Transitive", + "resolved": "1.21.4", + "contentHash": "g14EsSS85yn0lHTi0J9ivqlZMf09A2iI51fmI+0KkzIzyCbWOBWPi5mdaY7YWmXprk12aYh9u/qfWHQUYthlwg==" + }, + "MessagePack": { + "type": "Transitive", + "resolved": "2.5.192", + "contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==", + "dependencies": { + "MessagePack.Annotations": "2.5.192", + "Microsoft.NET.StringTools": "17.6.3" + } + }, + "MessagePack.Annotations": { + "type": "Transitive", + "resolved": "2.5.192", + "contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg==" + }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.6.3", + "contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA==" + }, + "Microsoft.VisualStudio.Threading": { + "type": "Transitive", + "resolved": "17.14.15", + "contentHash": "1DrCusT3xNLSlaJg77BsUSAzrhjdZBAvvsS0PMzyPM+fGais6SnISOhqdZQop8VVMIBLsYm2gyF9W7THjgavwA==", + "dependencies": { + "Microsoft.VisualStudio.Threading.Analyzers": "17.14.15", + "Microsoft.VisualStudio.Threading.Only": "17.14.15", + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Microsoft.VisualStudio.Threading.Analyzers": { + "type": "Transitive", + "resolved": "17.14.15", + "contentHash": "mXQPJsbuUD2ydq4/ffd8h8tSOFCXec+2xJOVNCvXjuMOq/+5EKHq3D2m2MC2+nUaXeFMSt66VS/J4HdKBixgcw==" + }, + "Microsoft.VisualStudio.Threading.Only": { + "type": "Transitive", + "resolved": "17.14.15", + "contentHash": "NqONyw1RXyj9P3k5e1uU2k9kc1ptwuU5NJQzG+MPq7vQVHUzBY8HLuJf/N2Rw5H/myD96CVxziDxmjawPuzntw==", + "dependencies": { + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "17.8.8", + "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "2nXPrhdAyAzir0gLl8Yy8S5Mnm/uBSQQA7jEsILOS1MTyS7DbmV1NgViMtvV1sfCD1ebITpNwb1NIinKeJgUVQ==" + }, + "Mono.Cecil": { + "type": "Transitive", + "resolved": "0.9.6.1", + "contentHash": "yMsurNaOxxKIjyW9pEB+tRrR1S3DFnN1+iBgKvYvXG8kW0Y6yknJeMAe/tl3+P78/2C6304TgF7aVqpqXgEQ9Q==" + }, + "Nerdbank.Streams": { + "type": "Transitive", + "resolved": "2.12.87", + "contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==", + "dependencies": { + "Microsoft.VisualStudio.Threading.Only": "17.13.61", + "Microsoft.VisualStudio.Validation": "17.8.8", + "System.IO.Pipelines": "8.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NHotkey": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "IEghs0QqWsQYH0uUmvIl0Ye6RaebWRh38eB6ToOkDnQucTYRGFOgtig0gSxlwCszTilYFz3n1ZuY762x+kDR3A==" + }, + "NHotkey.Wpf": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "BIUKlhTG5KtFf9OQzWvkmVmktt5/FFj6AOEgag8Uf0R2YdZt5ajUzs3sVskcJcT2TztWlEHKQr1jFj3KQ0D9Nw==", + "dependencies": { + "NHotkey": "3.0.0" + } + }, + "NLog": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "Xr+lIk1ZlTTFXEqnxQVLxrDqZlt2tm5X+/AhJbaY2emb/dVtGDiU5QuEtj3gHtwV/SWlP/rJ922I/BPuOJXlRw==" + }, + "NLog.OutputDebugString": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "TOP2Ap9BbE98B/l/TglnguowOD0rXo8B/20xAgvj9shO/kf6IJ5M4QMhVxq72mrneJ/ANhHY7Jcd+xJbzuI5PA==", + "dependencies": { + "NLog": "6.0.4" + } + }, + "SharpVectors.Wpf": { + "type": "Transitive", + "resolved": "1.8.5", + "contentHash": "WURdBDq5AE8RjKV9pFS7lNkJe81gxja9SaMGE4URq9GJUZ6M+5DGUL0Lm3B0iYW2/Meyowaz4ffGsyW+RBSTtg==" + }, + "Splat": { + "type": "Transitive", + "resolved": "1.6.2", + "contentHash": "DeH0MxPU+D4JchkIDPYG4vUT+hsWs9S41cFle0/4K5EJMXWurx5DzAkj2366DfK14/XKNhsu6tCl4dZXJ3CD4w==" + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "KIX+oBU38pxkKPxvLcLfIkOV5Ien8ReN78wro7OF5/erwcmortzeFx+iBswlh2Vz6gVne0khocQudGwaO1Ey6A==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "7.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5NecZgXktdGg34rh1OenY1rFNDCI8xSjFr+Z4OU4cU06AQHUdRnIIEeWENu3Wl4YowbzkymAIMvi3WyK9U53pQ==" + }, + "ToolGood.Words.Pinyin": { + "type": "Transitive", + "resolved": "3.1.0.3", + "contentHash": "VKcf8sUq/+LyY99WgLhOu7Q32ROEyR30/2xCCj9ADRi45wVC7kpXrYCf9vH1qirkmrIfpL8inoxAbrqAlfXxsQ==" + }, + "YamlDotNet": { + "type": "Transitive", + "resolved": "9.1.0", + "contentHash": "fuvGXU4Ec5HrsmEc+BiFTNPCRf1cGBI2kh/3RzMWgddM2M4ALhbSPoI3X3mhXZUD1qqQd9oSkFAtWjpz8z9eRg==" + }, + "flow.launcher.infrastructure": { + "type": "Project", + "dependencies": { + "Ben.Demystifier": "[0.4.1, )", + "BitFaster.Caching": "[2.5.4, )", + "CommunityToolkit.Mvvm": "[8.4.0, )", + "Flow.Launcher.Plugin": "[5.0.0, )", + "InputSimulator": "[1.0.4, )", + "MemoryPack": "[1.21.4, )", + "Microsoft.VisualStudio.Threading": "[17.14.15, )", + "NHotkey.Wpf": "[3.0.0, )", + "NLog": "[6.0.4, )", + "NLog.OutputDebugString": "[6.0.4, )", + "SharpVectors.Wpf": "[1.8.5, )", + "System.Drawing.Common": "[7.0.0, )", + "ToolGood.Words.Pinyin": "[3.1.0.3, )" + } + }, + "flow.launcher.plugin": { + "type": "Project", + "dependencies": { + "JetBrains.Annotations": "[2025.2.2, )" + } + } + } + } +} \ No newline at end of file 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/DialogJump/DialogJump.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs new file mode 100644 index 000000000..65652878f --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs @@ -0,0 +1,1079 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.DialogJump.Models; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using NHotkey; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Accessibility; + +namespace Flow.Launcher.Infrastructure.DialogJump +{ + public static class DialogJump + { + #region Public Properties + + public static Func ShowDialogJumpWindowAsync { get; set; } = null; + + public static Action UpdateDialogJumpWindow { get; set; } = null; + + public static Action ResetDialogJumpWindow { get; set; } = null; + + public static Action HideDialogJumpWindow { get; set; } = null; + + public static DialogJumpWindowPositions DialogJumpWindowPosition { get; private set; } + + public static DialogJumpExplorerPair WindowsDialogJumpExplorer { get; } = new() + { + Metadata = new() + { + ID = "298b197c08a24e90ab66ac060ee2b6b8", // ID is for calculating the hash id of the Dialog Jump pairs + Disabled = false // Disabled is for enabling the Windows DialogJump explorers & dialogs + }, + Plugin = new WindowsExplorer() + }; + + public static DialogJumpDialogPair WindowsDialogJumpDialog { get; } = new() + { + Metadata = new() + { + ID = "a4a113dc51094077ab4abb391e866c7b", // ID is for calculating the hash id of the Dialog Jump pairs + Disabled = false // Disabled is for enabling the Windows DialogJump explorers & dialogs + }, + Plugin = new WindowsDialog() + }; + + #endregion + + #region Private Fields + + private static readonly string ClassName = nameof(DialogJump); + + private static readonly Settings _settings = Ioc.Default.GetRequiredService(); + + // 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 static HWND _mainWindowHandle = HWND.Null; + + private static readonly Dictionary _dialogJumpExplorers = new(); + + private static DialogJumpExplorerPair _lastExplorer = null; + private static readonly object _lastExplorerLock = new(); + + private static readonly Dictionary _dialogJumpDialogs = new(); + + private static IDialogJumpDialogWindow _dialogWindow = null; + private static readonly object _dialogWindowLock = new(); + + private static HWINEVENTHOOK _foregroundChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _locationChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _destroyChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _hideChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _dialogEndChangeHook = HWINEVENTHOOK.Null; + + private static readonly WINEVENTPROC _fgProc = ForegroundChangeCallback; + private static readonly WINEVENTPROC _locProc = LocationChangeCallback; + private static readonly WINEVENTPROC _desProc = DestroyChangeCallback; + private static readonly WINEVENTPROC _hideProc = HideChangeCallback; + private static readonly WINEVENTPROC _dialogEndProc = DialogEndChangeCallback; + + private static DispatcherTimer _dragMoveTimer = null; + + // A list of all file dialog windows that are auto switched already + private static readonly List _autoSwitchedDialogs = new(); + private static readonly object _autoSwitchedDialogsLock = new(); + + private static HWINEVENTHOOK _moveSizeHook = HWINEVENTHOOK.Null; + private static readonly WINEVENTPROC _moveProc = MoveSizeCallBack; + + private static readonly SemaphoreSlim _foregroundChangeLock = new(1, 1); + private static readonly SemaphoreSlim _navigationLock = new(1, 1); + + private static bool _initialized = false; + private static bool _enabled = false; + + #endregion + + #region Initialize & Setup + + public static void InitializeDialogJump(IList dialogJumpExplorers, + IList dialogJumpDialogs) + { + if (_initialized) return; + + // Initialize Dialog Jump explorers & dialogs + _dialogJumpExplorers.Add(WindowsDialogJumpExplorer, null); + foreach (var explorer in dialogJumpExplorers) + { + _dialogJumpExplorers.Add(explorer, null); + } + _dialogJumpDialogs.Add(WindowsDialogJumpDialog, null); + foreach (var dialog in dialogJumpDialogs) + { + _dialogJumpDialogs.Add(dialog, null); + } + + // Initialize main window handle + _mainWindowHandle = Win32Helper.GetMainWindowHandle(); + + // Initialize timer + _dragMoveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; + _dragMoveTimer.Tick += (s, e) => InvokeUpdateDialogJumpWindow(); + + // Initialize Dialog Jump window position + DialogJumpWindowPosition = _settings.DialogJumpWindowPosition; + + _initialized = true; + } + + public static void SetupDialogJump(bool enabled) + { + if (enabled == _enabled) return; + + if (enabled) + { + // Check if there are explorer windows and get the topmost one + try + { + if (RefreshLastExplorer()) + { + Log.Debug(ClassName, $"Explorer window found"); + } + } + catch (System.Exception) + { + // Ignored + } + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = HWINEVENTHOOK.Null; + } + + // Hook events + _foregroundChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_FOREGROUND, + PInvoke.EVENT_SYSTEM_FOREGROUND, + PInvoke.GetModuleHandle((PCWSTR)null), + _fgProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _locationChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_LOCATIONCHANGE, + PInvoke.EVENT_OBJECT_LOCATIONCHANGE, + PInvoke.GetModuleHandle((PCWSTR)null), + _locProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _destroyChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_DESTROY, + PInvoke.EVENT_OBJECT_DESTROY, + PInvoke.GetModuleHandle((PCWSTR)null), + _desProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _hideChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_HIDE, + PInvoke.EVENT_OBJECT_HIDE, + PInvoke.GetModuleHandle((PCWSTR)null), + _hideProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _dialogEndChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_DIALOGEND, + PInvoke.EVENT_SYSTEM_DIALOGEND, + PInvoke.GetModuleHandle((PCWSTR)null), + _dialogEndProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + + if (_foregroundChangeHook.IsNull || + _locationChangeHook.IsNull || + _destroyChangeHook.IsNull || + _hideChangeHook.IsNull || + _dialogEndChangeHook.IsNull) + { + Log.Error(ClassName, "Failed to enable DialogJump"); + return; + } + } + else + { + // Remove explorer windows + foreach (var explorer in _dialogJumpExplorers.Keys) + { + _dialogJumpExplorers[explorer] = null; + } + + // Remove dialog windows + foreach (var dialog in _dialogJumpDialogs.Keys) + { + _dialogJumpDialogs[dialog] = null; + } + + // Remove dialog window handle + var dialogWindowExists = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + _dialogWindow = null; + dialogWindowExists = true; + } + } + + // Remove auto switched dialogs + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Clear(); + } + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = HWINEVENTHOOK.Null; + } + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Reset Dialog Jump window + if (dialogWindowExists) + { + InvokeResetDialogJumpWindow(); + } + } + + _enabled = enabled; + } + + private static bool RefreshLastExplorer() + { + var found = false; + + lock (_lastExplorerLock) + { + // Enum windows from the top to the bottom + PInvoke.EnumWindows((hWnd, _) => + { + foreach (var explorer in _dialogJumpExplorers.Keys) + { + if (API.PluginModified(explorer.Metadata.ID) || // Plugin is modified + explorer.Metadata.Disabled) continue; // Plugin is disabled + + var explorerWindow = explorer.Plugin.CheckExplorerWindow(hWnd); + if (explorerWindow != null) + { + _dialogJumpExplorers[explorer] = explorerWindow; + _lastExplorer = explorer; + found = true; + return false; + } + } + + // If we reach here, it means that the window is not a file explorer + return true; + }, IntPtr.Zero); + } + + return found; + } + + #endregion + + #region Active Explorer + + public static string GetActiveExplorerPath() + { + return RefreshLastExplorer() ? _dialogJumpExplorers[_lastExplorer].GetExplorerPath() : string.Empty; + } + + #endregion + + #region Events + + #region Invoke Property Events + + private static async Task InvokeShowDialogJumpWindowAsync(bool dialogWindowChanged) + { + // Show Dialog Jump window + if (_settings.ShowDialogJumpWindow) + { + // Save Dialog Jump window position for one file dialog + if (dialogWindowChanged) + { + DialogJumpWindowPosition = _settings.DialogJumpWindowPosition; + } + + // Call show Dialog Jump window + IDialogJumpDialogWindow dialogWindow; + lock (_dialogWindowLock) + { + dialogWindow = _dialogWindow; + } + if (dialogWindow != null && ShowDialogJumpWindowAsync != null) + { + await ShowDialogJumpWindowAsync.Invoke(dialogWindow.Handle); + } + + // Hook move size event if Dialog Jump window is under dialog & dialog window changed + if (DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) + { + if (dialogWindowChanged) + { + HWND dialogWindowHandle = HWND.Null; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + dialogWindowHandle = new(_dialogWindow.Handle); + } + } + + if (dialogWindowHandle == HWND.Null) return; + + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + + // Call _moveProc when the window is moved or resized + SetMoveProc(dialogWindowHandle); + } + } + } + + static unsafe void SetMoveProc(HWND handle) + { + uint processId; + var threadId = PInvoke.GetWindowThreadProcessId(handle, &processId); + _moveSizeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_MOVESIZESTART, + PInvoke.EVENT_SYSTEM_MOVESIZEEND, + PInvoke.GetModuleHandle((PCWSTR)null), + _moveProc, + processId, + threadId, + PInvoke.WINEVENT_OUTOFCONTEXT); + } + } + + private static void InvokeUpdateDialogJumpWindow() + { + UpdateDialogJumpWindow?.Invoke(); + } + + private static void InvokeResetDialogJumpWindow() + { + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + // Reset Dialog Jump window + ResetDialogJumpWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Unhook move size event + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + } + + private static void InvokeHideDialogJumpWindow() + { + // Hide Dialog Jump window + HideDialogJumpWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + } + + #endregion + + #region Hotkey + + public static void OnToggleHotkey(object sender, HotkeyEventArgs args) + { + _ = Task.Run(async () => + { + try + { + await NavigateDialogPathAsync(PInvoke.GetForegroundWindow()); + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to navigate dialog path", ex); + } + }); + } + + #endregion + + #region Windows Events + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] + private static async void ForegroundChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + await _foregroundChangeLock.WaitAsync(); + try + { + // Check if it is a file dialog window + var isDialogWindow = false; + var dialogWindowChanged = false; + foreach (var dialog in _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + IDialogJumpDialogWindow dialogWindow; + var existingDialogWindow = _dialogJumpDialogs[dialog]; + if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) + { + // If the dialog window is already in the list, no need to check again + dialogWindow = existingDialogWindow; + } + else + { + dialogWindow = dialog.Plugin.CheckDialogWindow(hwnd); + } + + // If the dialog window is found, set it + if (dialogWindow != null) + { + lock (_dialogWindowLock) + { + dialogWindowChanged = _dialogWindow == null || _dialogWindow.Handle != hwnd; + _dialogWindow = dialogWindow; + } + + isDialogWindow = true; + break; + } + } + + // Handle window based on its type + if (isDialogWindow) + { + Log.Debug(ClassName, $"Dialog Window: {hwnd}"); + // Navigate to path + if (_settings.AutoDialogJump) + { + // Check if we have already switched for this dialog + bool alreadySwitched; + lock (_autoSwitchedDialogsLock) + { + alreadySwitched = _autoSwitchedDialogs.Contains(hwnd); + } + + // Just show Dialog Jump window + if (alreadySwitched) + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + // Show Dialog Jump window after navigating the path + else + { + if (!await Task.Run(async () => + { + try + { + return await NavigateDialogPathAsync(hwnd, true); + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to navigate dialog path", ex); + return false; + } + })) + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + } + } + else + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + } + // Dialog jump window + else if (hwnd == _mainWindowHandle) + { + Log.Debug(ClassName, $"Main Window: {hwnd}"); + } + // Other window + else + { + Log.Debug(ClassName, $"Other Window: {hwnd}"); + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + dialogWindowExist = true; + } + } + if (dialogWindowExist) // Neither Dialog Jump window nor file dialog window is foreground + { + // Hide Dialog Jump window until the file dialog window is brought to the foreground + InvokeHideDialogJumpWindow(); + } + + // Check if there are foreground explorer windows + try + { + lock (_lastExplorerLock) + { + foreach (var explorer in _dialogJumpExplorers.Keys) + { + if (API.PluginModified(explorer.Metadata.ID) || // Plugin is modified + explorer.Metadata.Disabled) continue; // Plugin is disabled + + var explorerWindow = explorer.Plugin.CheckExplorerWindow(hwnd); + if (explorerWindow != null) + { + Log.Debug(ClassName, $"Explorer window: {hwnd}"); + _dialogJumpExplorers[explorer] = explorerWindow; + _lastExplorer = explorer; + break; + } + } + } + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "An error occurred while checking foreground explorer windows", ex); + } + } + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to invoke ForegroundChangeCallback", ex); + } + finally + { + _foregroundChangeLock.Release(); + } + } + + private static void LocationChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is moved, update the Dialog Jump window position + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + InvokeUpdateDialogJumpWindow(); + } + } + + private static void MoveSizeCallBack( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is moved or resized, update the Dialog Jump window position + if (_dragMoveTimer != null) + { + switch (eventType) + { + case PInvoke.EVENT_SYSTEM_MOVESIZESTART: + _dragMoveTimer.Start(); // Start dragging position + break; + case PInvoke.EVENT_SYSTEM_MOVESIZEEND: + _dragMoveTimer.Stop(); // Stop dragging + break; + } + } + } + + private static void DestroyChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is destroyed, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"Destory dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetDialogJumpWindow(); + } + } + + private static void HideChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is hidden, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"Hide dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetDialogJumpWindow(); + } + } + + private static void DialogEndChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is ended, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"End dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetDialogJumpWindow(); + } + } + + #endregion + + #endregion + + #region Path Navigation + + // Edited from: https://github.com/idkidknow/Flow.Launcher.Plugin.DirQuickJump + + public static async Task JumpToPathAsync(nint hwnd, string path) + { + // Check handle + if (hwnd == nint.Zero) return false; + + // Check path null or empty + if (string.IsNullOrEmpty(path)) return false; + + // Check path + if (!CheckPath(path, out var isFile)) return false; + + // Get dialog tab + var dialogWindowTab = GetDialogWindowTab(new(hwnd)); + if (dialogWindowTab == null) return false; + + return await JumpToPathAsync(dialogWindowTab, path, isFile, false); + } + + private static async Task NavigateDialogPathAsync(HWND hwnd, bool auto = false) + { + // Check handle + if (hwnd == HWND.Null) return false; + + // Get explorer path + string path; + lock (_lastExplorerLock) + { + path = _dialogJumpExplorers[_lastExplorer]?.GetExplorerPath(); + } + + // Check path null or empty + if (string.IsNullOrEmpty(path)) return false; + + // Check path + if (!CheckPath(path, out var isFile)) return false; + + // Get dialog tab + var dialogWindowTab = GetDialogWindowTab(hwnd); + if (dialogWindowTab == null) return false; + + // Jump to path + return await JumpToPathAsync(dialogWindowTab, path, isFile, auto); + } + + private static bool CheckPath(string path, out bool file) + { + file = false; + try + { + // shell: and shell::: paths + if (path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // file: URI paths + var localPath = path.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + ? new Uri(path).LocalPath + : path; + // Is folder? + var isFolder = Directory.Exists(localPath); + // Is file? + var isFile = File.Exists(localPath); + file = isFile; + return isFolder || isFile; + } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to check path", e); + return false; + } + } + + private static IDialogJumpDialogWindowTab GetDialogWindowTab(HWND hwnd) + { + var dialogWindow = GetDialogWindow(hwnd); + if (dialogWindow == null) return null; + var dialogWindowTab = dialogWindow.GetCurrentTab(); + return dialogWindowTab; + } + + private static IDialogJumpDialogWindow GetDialogWindow(HWND hwnd) + { + // First check dialog window + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + return _dialogWindow; + } + } + + // Then check all dialog windows + foreach (var dialog in _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + var dialogWindow = _dialogJumpDialogs[dialog]; + if (dialogWindow != null && dialogWindow.Handle == hwnd) + { + return dialogWindow; + } + } + + // Finally search for the dialog window again + foreach (var dialog in _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + IDialogJumpDialogWindow dialogWindow; + var existingDialogWindow = _dialogJumpDialogs[dialog]; + if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) + { + // If the dialog window is already in the list, no need to check again + dialogWindow = existingDialogWindow; + } + else + { + dialogWindow = dialog.Plugin.CheckDialogWindow(hwnd); + } + + // Update dialog window if found + if (dialogWindow != null) + { + _dialogJumpDialogs[dialog] = dialogWindow; + return dialogWindow; + } + } + + return null; + } + + private static async Task JumpToPathAsync(IDialogJumpDialogWindowTab dialog, string path, bool isFile, bool auto = false) + { + // Jump after flow launcher window vanished (after JumpAction returned true) + // and the dialog had been in the foreground. + var dialogHandle = dialog.Handle; + var timeOut = !SpinWait.SpinUntil(() => Win32Helper.IsForegroundWindow(dialogHandle), 1000); + if (timeOut) return false; + + // Assume that the dialog is in the foreground now + await _navigationLock.WaitAsync(); + try + { + bool result; + if (isFile) + { + switch (_settings.DialogJumpFileResultBehaviour) + { + case DialogJumpFileResultBehaviours.FullPath: + Log.Debug(ClassName, $"File Jump FullPath: {path}"); + result = FileJump(path, dialog); + break; + case DialogJumpFileResultBehaviours.FullPathOpen: + Log.Debug(ClassName, $"File Jump FullPathOpen: {path}"); + result = FileJump(path, dialog, openFile: true); + break; + case DialogJumpFileResultBehaviours.Directory: + Log.Debug(ClassName, $"File Jump Directory (Auto: {auto}): {path}"); + result = DirJump(Path.GetDirectoryName(path), dialog, auto); + break; + default: + return false; + } + } + else + { + Log.Debug(ClassName, $"Dir Jump: {path}"); + result = DirJump(path, dialog, auto); + } + + if (result) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Add(new(dialogHandle)); + } + } + + return result; + } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to jump to path", e); + return false; + } + finally + { + _navigationLock.Release(); + } + } + + private static bool FileJump(string filePath, IDialogJumpDialogWindowTab dialog, bool openFile = false) + { + if (!dialog.JumpFile(filePath)) + { + Log.Error(ClassName, "Failed to jump file"); + return false; + } + + if (openFile && !dialog.Open()) + { + Log.Error(ClassName, "Failed to open file"); + return false; + } + + return true; + } + + private static bool DirJump(string dirPath, IDialogJumpDialogWindowTab dialog, bool auto = false) + { + if (!dialog.JumpFolder(dirPath, auto)) + { + Log.Error(ClassName, "Failed to jump folder"); + return false; + } + + return true; + } + + #endregion + + #region Dispose + + public static void Dispose() + { + // Reset flags + _enabled = false; + _initialized = false; + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = HWINEVENTHOOK.Null; + } + + // Dispose explorers + foreach (var explorer in _dialogJumpExplorers.Keys) + { + _dialogJumpExplorers[explorer]?.Dispose(); + } + _dialogJumpExplorers.Clear(); + lock (_lastExplorerLock) + { + _lastExplorer = null; + } + + // Dispose dialogs + foreach (var dialog in _dialogJumpDialogs.Keys) + { + _dialogJumpDialogs[dialog]?.Dispose(); + } + _dialogJumpDialogs.Clear(); + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + // Dispose locks + _foregroundChangeLock.Dispose(); + _navigationLock.Dispose(); + + // Stop drag move timer + if (_dragMoveTimer != null) + { + _dragMoveTimer.Stop(); + _dragMoveTimer = null; + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Infrastructure/DialogJump/DialogJumpPair.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJumpPair.cs new file mode 100644 index 000000000..d1248eac1 --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJumpPair.cs @@ -0,0 +1,63 @@ +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Infrastructure.DialogJump; + +public class DialogJumpExplorerPair +{ + public IDialogJumpExplorer Plugin { get; init; } + + public PluginMetadata Metadata { get; init; } + + public override string ToString() + { + return Metadata.Name; + } + + public override bool Equals(object obj) + { + if (obj is DialogJumpExplorerPair r) + { + return string.Equals(r.Metadata.ID, Metadata.ID); + } + else + { + return false; + } + } + + public override int GetHashCode() + { + var hashcode = Metadata.ID?.GetHashCode() ?? 0; + return hashcode; + } +} + +public class DialogJumpDialogPair +{ + public IDialogJumpDialog Plugin { get; init; } + + public PluginMetadata Metadata { get; init; } + + public override string ToString() + { + return Metadata.Name; + } + + public override bool Equals(object obj) + { + if (obj is DialogJumpDialogPair r) + { + return string.Equals(r.Metadata.ID, Metadata.ID); + } + else + { + return false; + } + } + + public override int GetHashCode() + { + var hashcode = Metadata.ID?.GetHashCode() ?? 0; + return hashcode; + } +} diff --git a/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs new file mode 100644 index 000000000..ee4e03433 --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs @@ -0,0 +1,345 @@ +using System; +using System.Threading; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; +using WindowsInput; +using WindowsInput.Native; + +namespace Flow.Launcher.Infrastructure.DialogJump.Models +{ + /// + /// Class for handling Windows File Dialog instances in DialogJump. + /// + public class WindowsDialog : IDialogJumpDialog + { + private const string WindowsDialogClassName = "#32770"; + + public IDialogJumpDialogWindow CheckDialogWindow(IntPtr hwnd) + { + // Is it a Win32 dialog box? + if (GetClassName(new(hwnd)) == WindowsDialogClassName) + { + // Is it a windows file dialog? + var dialogType = GetFileDialogType(new(hwnd)); + if (dialogType != DialogType.Others) + { + return new WindowsDialogWindow(hwnd, dialogType); + } + } + + return null; + } + + public void Dispose() + { + + } + + #region Help Methods + + private static unsafe string GetClassName(HWND handle) + { + fixed (char* buf = new char[256]) + { + return PInvoke.GetClassName(handle, buf, 256) switch + { + 0 => string.Empty, + _ => new string(buf), + }; + } + } + + private static DialogType GetFileDialogType(HWND handle) + { + // Is it a Windows Open file dialog? + var fileEditor = PInvoke.GetDlgItem(handle, 0x047C); + if (fileEditor != HWND.Null && GetClassName(fileEditor) == "ComboBoxEx32") return DialogType.Open; + + // Is it a Windows Save or Save As file dialog? + fileEditor = PInvoke.GetDlgItem(handle, 0x0000); + if (fileEditor != HWND.Null && GetClassName(fileEditor) == "DUIViewWndClassName") return DialogType.SaveOrSaveAs; + + return DialogType.Others; + } + + #endregion + } + + public class WindowsDialogWindow : IDialogJumpDialogWindow + { + public IntPtr Handle { get; private set; } = IntPtr.Zero; + + // After jumping folder, file editor handle of Save / SaveAs file dialogs cannot be found anymore + // So we need to cache the current tab and use the original handle + private IDialogJumpDialogWindowTab _currentTab { get; set; } = null; + + private readonly DialogType _dialogType; + + internal WindowsDialogWindow(IntPtr handle, DialogType dialogType) + { + Handle = handle; + _dialogType = dialogType; + } + + public IDialogJumpDialogWindowTab GetCurrentTab() + { + return _currentTab ??= new WindowsDialogTab(Handle, _dialogType); + } + + public void Dispose() + { + + } + } + + public class WindowsDialogTab : IDialogJumpDialogWindowTab + { + #region Public Properties + + public IntPtr Handle { get; private set; } = IntPtr.Zero; + + #endregion + + #region Private Fields + + private static readonly string ClassName = nameof(WindowsDialogTab); + + private static readonly InputSimulator _inputSimulator = new(); + + private readonly DialogType _dialogType; + + private bool _legacy { get; set; } = false; + private HWND _pathControl { get; set; } = HWND.Null; + private HWND _pathEditor { get; set; } = HWND.Null; + private HWND _fileEditor { get; set; } = HWND.Null; + private HWND _openButton { get; set; } = HWND.Null; + + #endregion + + #region Constructor + + internal WindowsDialogTab(IntPtr handle, DialogType dialogType) + { + Handle = handle; + _dialogType = dialogType; + Log.Debug(ClassName, $"File dialog type: {dialogType}"); + } + + #endregion + + #region Public Methods + + public string GetCurrentFolder() + { + if (_pathEditor.IsNull && !GetPathControlEditor()) return string.Empty; + return GetWindowText(_pathEditor); + } + + public string GetCurrentFile() + { + if (_fileEditor.IsNull && !GetFileEditor()) return string.Empty; + return GetWindowText(_fileEditor); + } + + public bool JumpFolder(string path, bool auto) + { + if (auto) + { + // Use legacy jump folder method for auto Dialog Jump because file editor is default value. + // After setting path using file editor, we do not need to revert its value. + return JumpFolderWithFileEditor(path, false); + } + + // Alt-D or Ctrl-L to focus on the path input box + // "ComboBoxEx32" is not visible when the path editor is not with the keyboard focus + _inputSimulator.Keyboard.ModifiedKeyStroke(VirtualKeyCode.LMENU, VirtualKeyCode.VK_D); + // _inputSimulator.Keyboard.ModifiedKeyStroke(VirtualKeyCode.LCONTROL, VirtualKeyCode.VK_L); + + if (_pathControl.IsNull && !GetPathControlEditor()) + { + // https://github.com/idkidknow/Flow.Launcher.Plugin.DirQuickJump/issues/1 + // The dialog is a legacy one, so we can only edit file editor directly. + Log.Debug(ClassName, "Legacy dialog, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + + var timeOut = !SpinWait.SpinUntil(() => + { + var style = PInvoke.GetWindowLongPtr(_pathControl, WINDOW_LONG_PTR_INDEX.GWL_STYLE); + return (style & (int)WINDOW_STYLE.WS_VISIBLE) != 0; + }, 1000); + if (timeOut) + { + // Path control is not visible, so we can only edit file editor directly. + Log.Debug(ClassName, "Path control is not visible, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + + if (_pathEditor.IsNull) + { + // Path editor cannot be found, so we can only edit file editor directly. + Log.Debug(ClassName, "Path editor cannot be found, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + SetWindowText(_pathEditor, path); + + _inputSimulator.Keyboard.KeyPress(VirtualKeyCode.RETURN); + + return true; + } + + public bool JumpFile(string path) + { + if (_fileEditor.IsNull && !GetFileEditor()) return false; + SetWindowText(_fileEditor, path); + + return true; + } + + public bool Open() + { + if (_openButton.IsNull && !GetOpenButton()) return false; + PInvoke.PostMessage(_openButton, PInvoke.BM_CLICK, 0, 0); + + return true; + } + + public void Dispose() + { + + } + + #endregion + + #region Helper Methods + + #region Get Handles + + private bool GetPathControlEditor() + { + // Get the handle of the path editor + // Must use PInvoke.FindWindowEx because PInvoke.GetDlgItem(Handle, 0x0000) will get another control + _pathControl = PInvoke.FindWindowEx(new(Handle), HWND.Null, "WorkerW", null); // 0x0000 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "ReBarWindow32", null); // 0xA005 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "Address Band Root", null); // 0xA205 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "msctls_progress32", null); // 0x0000 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "ComboBoxEx32", null); // 0xA205 + if (_pathControl == HWND.Null) + { + _pathEditor = HWND.Null; + _legacy = true; + Log.Info(ClassName, "Legacy dialog"); + } + else + { + _pathEditor = PInvoke.GetDlgItem(_pathControl, 0xA205); // ComboBox + _pathEditor = PInvoke.GetDlgItem(_pathEditor, 0xA205); // Edit + if (_pathEditor == HWND.Null) + { + _legacy = true; + Log.Error(ClassName, "Failed to find path editor handle"); + } + } + + return !_legacy; + } + + private bool GetFileEditor() + { + if (_dialogType == DialogType.Open) + { + // Get the handle of the file name editor of Open file dialog + _fileEditor = PInvoke.GetDlgItem(new(Handle), 0x047C); // ComboBoxEx32 + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x047C); // ComboBox + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x047C); // Edit + } + else + { + // Get the handle of the file name editor of Save / SaveAs file dialog + _fileEditor = PInvoke.GetDlgItem(new(Handle), 0x0000); // DUIViewWndClassName + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // DirectUIHWND + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // FloatNotifySink + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // ComboBox + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x03E9); // Edit + } + + if (_fileEditor == HWND.Null) + { + Log.Error(ClassName, "Failed to find file name editor handle"); + return false; + } + + return true; + } + + private bool GetOpenButton() + { + // Get the handle of the open button + _openButton = PInvoke.GetDlgItem(new(Handle), 0x0001); // Open/Save/SaveAs Button + if (_openButton == HWND.Null) + { + Log.Error(ClassName, "Failed to find open button handle"); + return false; + } + + return true; + } + + #endregion + + #region Windows Text + + private static unsafe string GetWindowText(HWND handle) + { + int length; + Span buffer = stackalloc char[1000]; + fixed (char* pBuffer = buffer) + { + // If the control has no title bar or text, or if the control handle is invalid, the return value is zero. + length = (int)PInvoke.SendMessage(handle, PInvoke.WM_GETTEXT, 1000, (nint)pBuffer); + } + + return buffer[..length].ToString(); + } + + private static unsafe nint SetWindowText(HWND handle, string text) + { + fixed (char* textPtr = text + '\0') + { + return PInvoke.SendMessage(handle, PInvoke.WM_SETTEXT, 0, (nint)textPtr).Value; + } + } + + #endregion + + #region Legacy Jump Folder + + private bool JumpFolderWithFileEditor(string path, bool resetFocus) + { + // For Save / Save As dialog, the default value in file editor is not null and it can cause strange behaviors. + if (resetFocus && _dialogType == DialogType.SaveOrSaveAs) return false; + + if (_fileEditor.IsNull && !GetFileEditor()) return false; + SetWindowText(_fileEditor, path); + + if (_openButton.IsNull && !GetOpenButton()) return false; + PInvoke.SendMessage(_openButton, PInvoke.BM_CLICK, 0, 0); + + return true; + } + + #endregion + + #endregion + } + + internal enum DialogType + { + Others, + Open, + SaveOrSaveAs + } +} diff --git a/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsExplorer.cs b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsExplorer.cs new file mode 100644 index 000000000..e9ed9dae7 --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsExplorer.cs @@ -0,0 +1,260 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using Flow.Launcher.Plugin; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Flow.Launcher.Infrastructure.DialogJump.Models +{ + /// + /// Class for handling Windows Explorer instances in DialogJump. + /// + public class WindowsExplorer : IDialogJumpExplorer + { + public IDialogJumpExplorerWindow CheckExplorerWindow(IntPtr hwnd) + { + IDialogJumpExplorerWindow explorerWindow = null; + + // Is it from Explorer? + var processName = Win32Helper.GetProcessNameFromHwnd(new(hwnd)); + if (processName.Equals("explorer.exe", StringComparison.OrdinalIgnoreCase)) + { + EnumerateShellWindows((shellWindow) => + { + try + { + if (shellWindow is not IWebBrowser2 explorer) return true; + + if (explorer.HWND != hwnd) return true; + + explorerWindow = new WindowsExplorerWindow(hwnd); + return false; + } + catch + { + // Ignored + } + + return true; + }); + } + return explorerWindow; + } + + internal static unsafe void EnumerateShellWindows(Func action) + { + // Create an instance of ShellWindows + var clsidShellWindows = new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"); // ShellWindowsClass + var iidIShellWindows = typeof(IShellWindows).GUID; // IShellWindows + + var result = PInvoke.CoCreateInstance( + &clsidShellWindows, + null, + CLSCTX.CLSCTX_ALL, + &iidIShellWindows, + out var shellWindowsObj); + + if (result.Failed) return; + + var shellWindows = (IShellWindows)shellWindowsObj; + + // Enumerate the shell windows + var count = shellWindows.Count; + for (var i = 0; i < count; i++) + { + if (!action(shellWindows.Item(i))) + { + return; + } + } + } + + public void Dispose() + { + + } + } + + public class WindowsExplorerWindow : IDialogJumpExplorerWindow + { + public IntPtr Handle { get; } + + private static Guid _shellBrowserGuid = typeof(IShellBrowser).GUID; + + internal WindowsExplorerWindow(IntPtr handle) + { + Handle = handle; + } + + public string GetExplorerPath() + { + if (Handle == IntPtr.Zero) return null; + + var activeTabHandle = GetActiveTabHandle(new(Handle)); + if (activeTabHandle.IsNull) return null; + + var window = GetExplorerByTabHandle(activeTabHandle); + if (window == null) return null; + + var path = GetLocation(window); + return path; + } + + public void Dispose() + { + + } + + #region Helper Methods + + // Inspired by: https://github.com/w4po/ExplorerTabUtility + + private static HWND GetActiveTabHandle(HWND windowHandle) + { + // Active tab always at the top of the z-index, so it is the first child of the ShellTabWindowClass. + var activeTab = PInvoke.FindWindowEx(windowHandle, HWND.Null, "ShellTabWindowClass", null); + return activeTab; + } + + private static IWebBrowser2 GetExplorerByTabHandle(HWND tabHandle) + { + if (tabHandle.IsNull) return null; + + IWebBrowser2 window = null; + WindowsExplorer.EnumerateShellWindows((shellWindow) => + { + try + { + return StartSTAThread(() => + { + if (shellWindow is not IWebBrowser2 explorer) return true; + + if (explorer is not IServiceProvider sp) return true; + + sp.QueryService(ref _shellBrowserGuid, ref _shellBrowserGuid, out var shellBrowser); + if (shellBrowser == null) return true; + + try + { + shellBrowser.GetWindow(out var hWnd); // Must execute in STA thread to get this hWnd + + if (hWnd == tabHandle) + { + window = explorer; + return false; + } + } + catch + { + // Ignored + } + finally + { + Marshal.ReleaseComObject(shellBrowser); + } + + return true; + }) ?? true; + } + catch + { + // Ignored + } + + return true; + }); + + return window; + } + + private static bool? StartSTAThread(Func action) + { + bool? result = null; + var thread = new Thread(() => + { + result = action(); + }) + { + IsBackground = true + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return result; + } + + private static string GetLocation(IWebBrowser2 window) + { + var path = window.LocationURL.ToString(); + if (!string.IsNullOrWhiteSpace(path)) return NormalizeLocation(path); + + // Recycle Bin, This PC, etc + if (window.Document is not IShellFolderViewDual folderView) return null; + + // Attempt to get the path from the folder view + try + { + // CSWin32 Folder does not have Self, so we need to use dynamic type here + // Use dynamic to bypass static typing + dynamic folder = folderView.Folder; + + // Access the Self property via dynamic binding + dynamic folderItem = folder.Self; + + // Get path from the folder item + path = folderItem.Path; + } + catch + { + return null; + } + + return NormalizeLocation(path); + } + + private static string NormalizeLocation(string location) + { + if (location.IndexOf('%') > -1) + location = Environment.ExpandEnvironmentVariables(location); + + if (location.StartsWith("::", StringComparison.Ordinal)) + location = $"shell:{location}"; + + else if (location.StartsWith("{", StringComparison.Ordinal)) + location = $"shell:::{location}"; + + location = location.Trim(' ', '/', '\\', '\n', '\'', '"'); + + return location.Replace('/', '\\'); + } + + #endregion + } + + #region COM Interfaces + + // Inspired by: https://github.com/w4po/ExplorerTabUtility + + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("6d5140c1-7436-11ce-8034-00aa006009fa")] + [ComImport] + public interface IServiceProvider + { + [PreserveSig] + int QueryService(ref Guid guidService, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellBrowser ppvObject); + } + + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214E2-0000-0000-C000-000000000046")] + [ComImport] + public interface IShellBrowser + { + [PreserveSig] + int GetWindow(out nint handle); + } + + #endregion +} diff --git a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs index b97c096c3..6e2d86849 100644 --- a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs +++ b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Windows.Win32; namespace Flow.Launcher.Infrastructure { @@ -13,9 +9,10 @@ namespace Flow.Launcher.Infrastructure /// public static string GetActiveExplorerPath() { - var explorerWindow = GetActiveExplorer(); - string locationUrl = explorerWindow?.LocationURL; - return !string.IsNullOrEmpty(locationUrl) ? GetDirectoryPath(new Uri(locationUrl).LocalPath) : null; + var explorerPath = DialogJump.DialogJump.GetActiveExplorerPath(); + return !string.IsNullOrEmpty(explorerPath) ? + GetDirectoryPath(new Uri(explorerPath).LocalPath) : + null; } /// @@ -23,76 +20,12 @@ namespace Flow.Launcher.Infrastructure /// private static string GetDirectoryPath(string path) { - if (!path.EndsWith("\\")) + if (!path.EndsWith('\\')) { return path + "\\"; } return path; } - - /// - /// Gets the file explorer that is currently in the foreground - /// - private static dynamic GetActiveExplorer() - { - Type type = Type.GetTypeFromProgID("Shell.Application"); - if (type == null) return null; - dynamic shell = Activator.CreateInstance(type); - if (shell == null) - { - return null; - } - - var explorerWindows = new List(); - var openWindows = shell.Windows(); - for (int i = 0; i < openWindows.Count; i++) - { - var window = openWindows.Item(i); - if (window == null) continue; - - // find the desired window and make sure that it is indeed a file explorer - // we don't want the Internet Explorer or the classic control panel - // ToLower() is needed, because Windows can report the path as "C:\\Windows\\Explorer.EXE" - if (Path.GetFileName((string)window.FullName)?.ToLower() == "explorer.exe") - { - explorerWindows.Add(window); - } - } - - if (explorerWindows.Count == 0) return null; - - var zOrders = GetZOrder(explorerWindows); - - 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. - /// - private static IEnumerable GetZOrder(List hWnds) - { - var z = new int[hWnds.Count]; - for (var i = 0; i < hWnds.Count; i++) z[i] = -1; - - var index = 0; - var numRemaining = hWnds.Count; - PInvoke.EnumWindows((wnd, _) => - { - var searchIndex = hWnds.FindIndex(x => new IntPtr(x.HWND) == wnd); - if (searchIndex != -1) - { - z[searchIndex] = index; - numRemaining--; - if (numRemaining == 0) return false; - } - index++; - return true; - }, IntPtr.Zero); - - return z; - } } } diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index 1d6ee5c86..5b4eaf893 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -1,7 +1,7 @@ - + - net7.0-windows + net9.0-windows {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3} Library true @@ -12,6 +12,7 @@ false false true + true @@ -53,24 +54,30 @@ - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + all + + + + - - - + - \ No newline at end of file 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 0b3f2be65..8afab419b 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -1,31 +1,35 @@ -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 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(); - public static IPublicAPI API { get; set; } + // 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(); 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; @@ -35,7 +39,7 @@ namespace Flow.Launcher.Infrastructure.Http public static HttpProxy Proxy { - private get { return proxy; } + private get => proxy; set { proxy = value; @@ -73,13 +77,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) { - API.ShowMsg("Please try again", "Unable to parse Http Proxy"); - Log.Exception("Flow.Launcher.Infrastructure.Http", "Unable to parse Uri", e); + API.ShowMsgError(API.GetTranslation("pleaseTryAgain"), API.GetTranslation("parseProxyFailed")); + Log.Exception(ClassName, "Unable to parse Uri", e); } } @@ -135,7 +139,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; } } @@ -148,7 +152,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); } @@ -160,7 +164,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) @@ -182,7 +186,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. /// @@ -192,7 +195,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); } @@ -203,7 +206,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); } @@ -212,7 +215,27 @@ 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); + } + } + + public static async Task GetStringAsync(string url, CancellationToken token = default) + { + try + { + Log.Debug(ClassName, $"Url <{url}>"); + return await client.GetStringAsync(url, token); + } + catch (System.Exception e) + { + return string.Empty; + } } } } diff --git a/Flow.Launcher.Infrastructure/IAlphabet.cs b/Flow.Launcher.Infrastructure/IAlphabet.cs new file mode 100644 index 000000000..d13eeb414 --- /dev/null +++ b/Flow.Launcher.Infrastructure/IAlphabet.cs @@ -0,0 +1,22 @@ +namespace Flow.Launcher.Infrastructure +{ + /// + /// Translate a language to English letters using a given rule. + /// + public interface IAlphabet + { + /// + /// Translate a string to English letters, using a given rule. + /// + /// String to translate. + /// + public (string translation, TranslationMapping map) Translate(string stringToTranslate); + + /// + /// Determine if a string should be translated to English letter with this Alphabet. + /// + /// String to translate. + /// + public bool ShouldTranslate(string stringToTranslate); + } +} 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..598347fd2 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -5,88 +5,90 @@ 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 Lock storageLock { get; } = new(); private static BinaryStorage> _storage; private static readonly ConcurrentDictionary GuidToKey = new(); - private static IImageHashGenerator _hashGenerator; + private static ImageHashGenerator _hashGenerator; private static readonly bool EnableImageHash = true; - public static ImageSource Image { get; } = new BitmapImage(new Uri(Constant.ImageIcon)); - public static ImageSource MissingImage { get; } = new BitmapImage(new Uri(Constant.MissingImgIcon)); - public static ImageSource LoadingImage { get; } = new BitmapImage(new Uri(Constant.LoadingImgIcon)); + public static ImageSource Image => ImageCache[Constant.ImageIcon, false]; + public static ImageSource MissingImage => ImageCache[Constant.MissingImgIcon, false]; + public static ImageSource LoadingImage => ImageCache[Constant.LoadingImgIcon, false]; 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[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico"]; + private static readonly string SvgExtension = ".svg"; public static async Task InitializeAsync() { - _storage = new BinaryStorage>("Image"); - _hashGenerator = new ImageHashGenerator(); - - var usage = await LoadStorageToConcurrentDictionaryAsync(); - - ImageCache.Initialize(usage); - - foreach (var icon in new[] { Constant.DefaultIcon, Constant.MissingImgIcon }) + var usage = await Task.Run(() => { - ImageSource img = new BitmapImage(new Uri(icon)); - img.Freeze(); - ImageCache[icon, false] = img; - } + _storage = new BinaryStorage>("Image"); + _hashGenerator = new ImageHashGenerator(); + + var usage = LoadStorageToConcurrentDictionary(); + _storage.ClearData(); + + ImageCache.Initialize(usage); + + foreach (var icon in new[] { Constant.DefaultIcon, Constant.ImageIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon }) + { + ImageSource img = new BitmapImage(new Uri(icon)); + img.Freeze(); + ImageCache[icon, false] = img; + } + + return usage; + }); _ = 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 void Save() { - await storageLock.WaitAsync(); - - try + lock (storageLock) { - await _storage.SaveAsync(ImageCache.EnumerateEntries() - .Select(x => x.Key) - .ToList()); - } - finally - { - storageLock.Release(); + try + { + _storage.Save([.. ImageCache.EnumerateEntries().Select(x => x.Key)]); + } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to save image cache to file", e); + } } } - private static async Task> LoadStorageToConcurrentDictionaryAsync() + private static List<(string, bool)> LoadStorageToConcurrentDictionary() { - await storageLock.WaitAsync(); - try + lock (storageLock) { - return await _storage.TryLoadAsync(new List<(string, bool)>()); - } - finally - { - storageLock.Release(); + return _storage.TryLoad([]); } } @@ -158,10 +160,10 @@ 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]; + ImageSource image = MissingImage; ImageCache[path, false] = image; imageResult = new ImageResult(image, ImageType.Error); } @@ -173,7 +175,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 +223,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 +240,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; @@ -245,7 +262,7 @@ namespace Flow.Launcher.Infrastructure.Image } else { - image = ImageCache[Constant.MissingImgIcon, false]; + image = MissingImage; path = Constant.MissingImgIcon; } @@ -277,7 +294,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,16 +310,18 @@ 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; @@ -317,24 +336,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 +363,50 @@ namespace Flow.Launcher.Infrastructure.Image return image; } + + private static RenderTargetBitmap 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 d4bd473ac..2a5b826a9 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); @@ -34,7 +34,7 @@ namespace Flow.Launcher.Infrastructure.Logger var fileTarget = new FileTarget { - FileName = CurrentLogDirectory.Replace(@"\", "/") + "/${shortdate}.txt", + FileName = CurrentLogDirectory.Replace(@"\", "/") + "/Flow.Launcher.${date:format=yyyy-MM-dd}.log", Layout = layout }; @@ -48,17 +48,41 @@ namespace Flow.Launcher.Infrastructure.Logger configuration.AddTarget("file", fileTargetASyncWrapper); configuration.AddTarget("debug", debugTarget); + var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper) + { + RuleName = "file" + }; #if DEBUG - var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper); - var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget); + var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget) + { + RuleName = "debug" + }; configuration.LoggingRules.Add(debugRule); -#else - var fileRule = new LoggingRule("*", LogLevel.Info, fileTargetASyncWrapper); #endif configuration.LoggingRules.Add(fileRule); LogManager.Configuration = configuration; } + public static void SetLogLevel(LOGLEVEL level) + { + var rule = LogManager.Configuration.FindRuleByName("file"); + + var nlogLevel = level switch + { + LOGLEVEL.NONE => LogLevel.Off, + LOGLEVEL.ERROR => LogLevel.Error, + LOGLEVEL.DEBUG => LogLevel.Debug, + _ => LogLevel.Info + }; + + rule.SetLoggingLevels(nlogLevel, LogLevel.Fatal); + + LogManager.ReconfigExistingLoggers(); + + // We can't log Info when level is set to Error or None, so we use Debug + Debug(nameof(Logger), $"Using log level: {level}."); + } + private static void LogFaultyFormat(string message) { var logger = LogManager.GetLogger("FaultyLogger"); @@ -66,13 +90,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(); @@ -107,57 +124,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 = "") { @@ -178,32 +152,22 @@ 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 + { + NONE, + ERROR, + INFO, + DEBUG } } diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index f117534a1..eb844dd7c 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -11,9 +11,79 @@ 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 + +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 + +SetWinEventHook +UnhookWinEvent +SendMessage +EVENT_SYSTEM_FOREGROUND +WINEVENT_OUTOFCONTEXT +WM_SETTEXT +IShellFolderViewDual2 +CoCreateInstance +CLSCTX +IShellWindows +IWebBrowser2 +EVENT_OBJECT_DESTROY +EVENT_OBJECT_LOCATIONCHANGE +EVENT_SYSTEM_MOVESIZESTART +EVENT_SYSTEM_MOVESIZEEND +GetDlgItem +PostMessage +BM_CLICK +WM_GETTEXT +OpenProcess +QueryFullProcessImageName +EVENT_OBJECT_HIDE +EVENT_SYSTEM_DIALOGEND + +WM_POWERBROADCAST +PBT_APMRESUMEAUTOMATIC \ No newline at end of file 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/PinyinAlphabet.cs b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs index 7d7235968..1c0cc6872 100644 --- a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs +++ b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs @@ -1,203 +1,193 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; +using System.Collections.ObjectModel; +using System.IO; using System.Text; -using JetBrains.Annotations; +using System.Text.Json; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.UserSettings; using ToolGood.Words.Pinyin; +using Flow.Launcher.Infrastructure.Logger; namespace Flow.Launcher.Infrastructure { - public class TranslationMapping - { - private bool constructed; - - private List originalIndexs = new List(); - private List translatedIndexs = new List(); - private int translatedLength = 0; - - public string key { get; private set; } - - public void setKey(string key) - { - this.key = key; - } - - public void AddNewIndex(int originalIndex, int translatedIndex, int length) - { - if (constructed) - throw new InvalidOperationException("Mapping shouldn't be changed after constructed"); - - originalIndexs.Add(originalIndex); - translatedIndexs.Add(translatedIndex); - translatedIndexs.Add(translatedIndex + length); - translatedLength += length - 1; - } - - public int MapToOriginalIndex(int translatedIndex) - { - if (translatedIndex > translatedIndexs.Last()) - return translatedIndex - translatedLength - 1; - - int lowerBound = 0; - int upperBound = originalIndexs.Count - 1; - - int count = 0; - - // Corner case handle - if (translatedIndex < translatedIndexs[0]) - return translatedIndex; - if (translatedIndex > translatedIndexs.Last()) - { - int indexDef = 0; - for (int k = 0; k < originalIndexs.Count; k++) - { - indexDef += translatedIndexs[k * 2 + 1] - translatedIndexs[k * 2]; - } - - return translatedIndex - indexDef - 1; - } - - // Binary Search with Range - for (int i = originalIndexs.Count / 2;; count++) - { - if (translatedIndex < translatedIndexs[i * 2]) - { - // move to lower middle - upperBound = i; - i = (i + lowerBound) / 2; - } - else if (translatedIndex > translatedIndexs[i * 2 + 1] - 1) - { - lowerBound = i; - // move to upper middle - // due to floor of integer division, move one up on corner case - i = (i + upperBound + 1) / 2; - } - else - return originalIndexs[i]; - - if (upperBound - lowerBound <= 1 && - translatedIndex > translatedIndexs[lowerBound * 2 + 1] && - translatedIndex < translatedIndexs[upperBound * 2]) - { - int indexDef = 0; - - for (int j = 0; j < upperBound; j++) - { - indexDef += translatedIndexs[j * 2 + 1] - translatedIndexs[j * 2]; - } - - return translatedIndex - indexDef - 1; - } - } - } - - public void endConstruct() - { - if (constructed) - throw new InvalidOperationException("Mapping has already been constructed"); - constructed = true; - } - } - - /// - /// Translate a language to English letters using a given rule. - /// - public interface IAlphabet - { - /// - /// Translate a string to English letters, using a given rule. - /// - /// String to translate. - /// - public (string translation, TranslationMapping map) Translate(string stringToTranslate); - - /// - /// Determine if a string can be translated to English letter with this Alphabet. - /// - /// String to translate. - /// - public bool CanBeTranslated(string stringToTranslate); - } - public class PinyinAlphabet : IAlphabet { - private ConcurrentDictionary _pinyinCache = - new ConcurrentDictionary(); + private readonly ConcurrentDictionary _pinyinCache = new(); + private readonly Settings _settings; + private ReadOnlyDictionary currentDoublePinyinTable; - private Settings _settings; - - public void Initialize([NotNull] Settings settings) + public PinyinAlphabet() { - _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _settings = Ioc.Default.GetRequiredService(); + LoadDoublePinyinTable(); + + _settings.PropertyChanged += (sender, e) => + { + switch (e.PropertyName) + { + case nameof (Settings.ShouldUsePinyin): + if (_settings.ShouldUsePinyin) + { + Reload(); + } + break; + case nameof(Settings.UseDoublePinyin): + case nameof(Settings.DoublePinyinSchema): + if (_settings.UseDoublePinyin) + { + Reload(); + } + break; + } + }; } - public bool CanBeTranslated(string stringToTranslate) + public void Reload() { - return WordsHelper.HasChinese(stringToTranslate); + LoadDoublePinyinTable(); + _pinyinCache.Clear(); + } + + private void CreateDoublePinyinTableFromStream(Stream jsonStream) + { + var table = JsonSerializer.Deserialize>>(jsonStream) ?? + throw new InvalidOperationException("Failed to deserialize double pinyin table: result is null"); + + var schemaKey = _settings.DoublePinyinSchema.ToString(); + if (!table.TryGetValue(schemaKey, out var schemaDict)) + { + throw new ArgumentException($"DoublePinyinSchema '{schemaKey}' is invalid or double pinyin table is broken."); + } + + currentDoublePinyinTable = new ReadOnlyDictionary(schemaDict); + } + + private void LoadDoublePinyinTable() + { + if (!_settings.UseDoublePinyin) + { + currentDoublePinyinTable = new ReadOnlyDictionary(new Dictionary()); + return; + } + + var tablePath = Path.Combine(AppContext.BaseDirectory, "Resources", "double_pinyin.json"); + try + { + using var fs = File.OpenRead(tablePath); + CreateDoublePinyinTableFromStream(fs); + } + catch (FileNotFoundException e) + { + Log.Exception(nameof(PinyinAlphabet), $"Double pinyin table file not found: {tablePath}", e); + currentDoublePinyinTable = new ReadOnlyDictionary(new Dictionary()); + } + catch (DirectoryNotFoundException e) + { + Log.Exception(nameof(PinyinAlphabet), $"Directory not found for double pinyin table: {tablePath}", e); + currentDoublePinyinTable = new ReadOnlyDictionary(new Dictionary()); + } + catch (UnauthorizedAccessException e) + { + Log.Exception(nameof(PinyinAlphabet), $"Access denied to double pinyin table: {tablePath}", e); + currentDoublePinyinTable = new ReadOnlyDictionary(new Dictionary()); + } + catch (System.Exception e) + { + Log.Exception(nameof(PinyinAlphabet), $"Failed to load double pinyin table from file: {tablePath}", e); + currentDoublePinyinTable = new ReadOnlyDictionary(new Dictionary()); + } + } + + public bool ShouldTranslate(string stringToTranslate) + { + // If the query (stringToTranslate) does NOT contain Chinese characters, + // we should translate the target string to pinyin for matching + return _settings.ShouldUsePinyin && !ContainsChinese(stringToTranslate); } public (string translation, TranslationMapping map) Translate(string content) { - if (_settings.ShouldUsePinyin) - { - if (!_pinyinCache.ContainsKey(content)) - { - return BuildCacheFromContent(content); - } - else - { - return _pinyinCache[content]; - } - } - return (content, null); + if (!_settings.ShouldUsePinyin || !ContainsChinese(content)) + return (content, null); + + return _pinyinCache.TryGetValue(content, out var cached) ? cached : BuildCacheFromContent(content); } private (string translation, TranslationMapping map) BuildCacheFromContent(string content) { - if (WordsHelper.HasChinese(content)) + var resultList = WordsHelper.GetPinyinList(content); + var resultBuilder = new StringBuilder(_settings.UseDoublePinyin ? 3 : 4); // Pre-allocate with estimated capacity + var map = new TranslationMapping(); + + var previousIsChinese = false; + + for (var i = 0; i < resultList.Length; i++) { - var resultList = WordsHelper.GetPinyinList(content); - - StringBuilder resultBuilder = new StringBuilder(); - TranslationMapping map = new TranslationMapping(); - - bool pre = false; - - for (int i = 0; i < resultList.Length; i++) + if (IsChineseCharacter(content[i])) { - if (content[i] >= 0x3400 && content[i] <= 0x9FD5) + var translated = _settings.UseDoublePinyin ? ToDoublePinyin(resultList[i]) : resultList[i]; + + if (i > 0) { - map.AddNewIndex(i, resultBuilder.Length, resultList[i].Length + 1); resultBuilder.Append(' '); - resultBuilder.Append(resultList[i]); - pre = true; - } - else - { - if (pre) - { - pre = false; - resultBuilder.Append(' '); - } - - resultBuilder.Append(resultList[i]); } + + map.AddNewIndex(resultBuilder.Length, translated.Length); + resultBuilder.Append(translated); + previousIsChinese = true; + } + else + { + // Add space after Chinese characters before non-Chinese characters + if (previousIsChinese) + { + previousIsChinese = false; + resultBuilder.Append(' '); + } + + map.AddNewIndex(resultBuilder.Length, resultList[i].Length); + resultBuilder.Append(resultList[i]); } - - map.endConstruct(); - - var key = resultBuilder.ToString(); - map.setKey(key); - - return _pinyinCache[content] = (key, map); } - else + + map.EndConstruct(); + + var translation = resultBuilder.ToString(); + var result = (translation, map); + + return _pinyinCache[content] = result; + } + + /// + /// Optimized Chinese character detection using the comprehensive CJK Unicode ranges + /// + private static bool ContainsChinese(ReadOnlySpan text) + { + foreach (var c in text) { - return (content, null); + if (IsChineseCharacter(c)) + return true; } + return false; + } + + /// + /// Check if a character is a Chinese character using comprehensive Unicode ranges + /// Covers CJK Unified Ideographs, Extension A + /// + private static bool IsChineseCharacter(char c) + { + return (c >= 0x4E00 && c <= 0x9FFF) || // CJK Unified Ideographs + (c >= 0x3400 && c <= 0x4DBF); // CJK Extension A + } + + private string ToDoublePinyin(string fullPinyin) + { + return currentDoublePinyinTable.TryGetValue(fullPinyin, out var doublePinyinValue) + ? doublePinyinValue + : fullPinyin; } } } 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..15200f5aa 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -1,81 +1,170 @@ 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 { /// - /// Stroage object using binary data + /// Storage object using binary data /// 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; } - - public async ValueTask TryLoadAsync(T defaultData) + // 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 T TryLoad(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; + Save(); } - await using var stream = new FileStream(FilePath, FileMode.Open); - var d = await DeserializeAsync(stream, defaultData); - return d; + var bytes = File.ReadAllBytes(FilePath); + Data = Deserialize(bytes, defaultData); } else { - Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data"); - await SaveAsync(defaultData); + Log.Info(ClassName, "Cache file not exist, load default data"); + Data = defaultData; + Save(); + } + return Data; + } + + private T Deserialize(ReadOnlySpan bytes, T defaultData) + { + try + { + var t = MemoryPackSerializer.Deserialize(bytes); + return t ?? defaultData; + } + catch (System.Exception e) + { + Log.Exception(ClassName, $"Deserialize error for file <{FilePath}>", e); return defaultData; } } + public async ValueTask TryLoadAsync(T defaultData) + { + if (Data != null) return Data; + + if (File.Exists(FilePath)) + { + if (new FileInfo(FilePath).Length == 0) + { + Log.Error(ClassName, $"Zero length cache file <{FilePath}>"); + Data = defaultData; + await SaveAsync(); + } + + await using var stream = new FileStream(FilePath, FileMode.Open); + Data = await DeserializeAsync(stream, defaultData); + } + else + { + Log.Info(ClassName, "Cache file not exist, load default data"); + Data = defaultData; + await SaveAsync(); + } + + return Data; + } + private async ValueTask DeserializeAsync(Stream stream, T defaultData) { try { var t = await MemoryPackSerializer.DeserializeAsync(stream); - return t; + return t ?? defaultData; } catch (System.Exception e) { - // Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); + Log.Exception(ClassName, $"Deserialize error for file <{FilePath}>", e); return defaultData; } } + public void Save() + { + Save(Data.NonNull()); + } + + public void Save(T data) + { + // 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()); + } + 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); } + + // ImageCache need to convert data into concurrent dictionary for usage, + // so we would better to clear the data + public void ClearData() + { + Data = default; + } } } diff --git a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs index 865041fb3..857490bad 100644 --- a/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/FlowLauncherJsonStorage.cs @@ -1,17 +1,48 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { - public class FlowLauncherJsonStorage : JsonStorage where T : new() + // Expose ISaveable interface in derived class to make sure we are calling the new version of Save method + public class FlowLauncherJsonStorage : JsonStorage, ISavable 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 642250627..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!; @@ -31,12 +36,29 @@ namespace Flow.Launcher.Infrastructure.Storage protected JsonStorage() { } + public JsonStorage(string filePath) { 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() @@ -97,9 +119,10 @@ namespace Flow.Launcher.Infrastructure.Storage return default; } } + 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); @@ -178,26 +201,28 @@ namespace Flow.Launcher.Infrastructure.Storage public void Save() { - string serialized = JsonSerializer.Serialize(Data, - new JsonSerializerOptions - { - WriteIndented = true - }); + // 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); AtomicWriteSetting(); } + public async Task SaveAsync() { - var tempOutput = File.OpenWrite(TempFilePath); + // 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 - }); + new JsonSerializerOptions { WriteIndented = true }); AtomicWriteSetting(); } + private void AtomicWriteSetting() { if (!File.Exists(FilePath)) @@ -206,9 +231,9 @@ namespace Flow.Launcher.Infrastructure.Storage } else { - File.Replace(TempFilePath, FilePath, BackupFilePath); + var finalFilePath = new FileInfo(FilePath).LinkTarget ?? FilePath; + File.Replace(TempFilePath, finalFilePath, BackupFilePath); } } - } } diff --git a/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs new file mode 100644 index 000000000..0e0906e73 --- /dev/null +++ b/Flow.Launcher.Infrastructure/Storage/PluginBinaryStorage.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; + +namespace Flow.Launcher.Infrastructure.Storage +{ + // Expose ISaveable interface in derived class to make sure we are calling the new version of Save method + public class PluginBinaryStorage : BinaryStorage, ISavable 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 abe3f55b5..d59083071 100644 --- a/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/PluginJsonStorage.cs @@ -1,17 +1,27 @@ using System.IO; +using System.Threading.Tasks; +using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher.Infrastructure.Storage { - public class PluginJsonStorage :JsonStorage where T : new() + // Expose ISaveable interface in derived class to make sure we are calling the new version of Save method + public class PluginJsonStorage : JsonStorage, ISavable where T : new() { + // 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); - var assemblyName = dataType.Assembly.GetName().Name; - DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Plugins, assemblyName); - Helper.ValidateDirectory(DirectoryPath); + AssemblyName = dataType.Assembly.GetName().Name; + DirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, AssemblyName); + FilesFolders.ValidateDirectory(DirectoryPath); FilePath = Path.Combine(DirectoryPath, $"{dataType.Name}{FileSuffix}"); } @@ -20,6 +30,29 @@ namespace Flow.Launcher.Infrastructure.Storage { Data = data; } + + public new void Save() + { + try + { + 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/StringMatcher.cs b/Flow.Launcher.Infrastructure/StringMatcher.cs index bd5dbdda9..2882cb8f0 100644 --- a/Flow.Launcher.Infrastructure/StringMatcher.cs +++ b/Flow.Launcher.Infrastructure/StringMatcher.cs @@ -1,28 +1,35 @@ -using Flow.Launcher.Plugin.SharedModels; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin.SharedModels; using System; using System.Collections.Generic; using System.Linq; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher.Infrastructure { public class StringMatcher { - private readonly MatchOption _defaultMatchOption = new MatchOption(); + private readonly MatchOption _defaultMatchOption = new(); public SearchPrecisionScore UserSettingSearchPrecision { get; set; } private readonly IAlphabet _alphabet; - public StringMatcher(IAlphabet alphabet = null) + public StringMatcher(IAlphabet alphabet, Settings settings) + { + _alphabet = alphabet; + UserSettingSearchPrecision = settings.QuerySearchPrecision; + } + + // This is a workaround to allow unit tests to set the instance + public StringMatcher(IAlphabet alphabet) { _alphabet = alphabet; } - public static StringMatcher Instance { get; internal set; } - public static MatchResult FuzzySearch(string query, string stringToCompare) { - return Instance.FuzzyMatch(query, stringToCompare); + return Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); } public MatchResult FuzzyMatch(string query, string stringToCompare) @@ -61,7 +68,7 @@ namespace Flow.Launcher.Infrastructure query = query.Trim(); TranslationMapping translationMapping = null; - if (_alphabet is not null && !_alphabet.CanBeTranslated(query)) + if (_alphabet is not null && _alphabet.ShouldTranslate(query)) { // We assume that if a query can be translated (containing characters of a language, like Chinese) // it actually means user doesn't want it to be translated to English letters. @@ -221,7 +228,7 @@ namespace Flow.Launcher.Infrastructure return new MatchResult(false, UserSettingSearchPrecision); } - private bool IsAcronym(string stringToCompare, int compareStringIndex) + private static bool IsAcronym(string stringToCompare, int compareStringIndex) { if (IsAcronymChar(stringToCompare, compareStringIndex) || IsAcronymNumber(stringToCompare, compareStringIndex)) return true; @@ -230,7 +237,7 @@ namespace Flow.Launcher.Infrastructure } // When counting acronyms, treat a set of numbers as one acronym ie. Visual 2019 as 2 acronyms instead of 5 - private bool IsAcronymCount(string stringToCompare, int compareStringIndex) + private static bool IsAcronymCount(string stringToCompare, int compareStringIndex) { if (IsAcronymChar(stringToCompare, compareStringIndex)) return true; @@ -241,16 +248,16 @@ namespace Flow.Launcher.Infrastructure return false; } - private bool IsAcronymChar(string stringToCompare, int compareStringIndex) + private static bool IsAcronymChar(string stringToCompare, int compareStringIndex) => char.IsUpper(stringToCompare[compareStringIndex]) || compareStringIndex == 0 || // 0 index means char is the start of the compare string, which is an acronym char.IsWhiteSpace(stringToCompare[compareStringIndex - 1]); - private bool IsAcronymNumber(string stringToCompare, int compareStringIndex) + private static bool IsAcronymNumber(string stringToCompare, int compareStringIndex) => stringToCompare[compareStringIndex] >= 0 && stringToCompare[compareStringIndex] <= 9; // To get the index of the closest space which preceeds the first matching index - private int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) + private static int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) { var closestSpaceIndex = -1; diff --git a/Flow.Launcher.Infrastructure/TranslationMapping.cs b/Flow.Launcher.Infrastructure/TranslationMapping.cs new file mode 100644 index 000000000..b4c6764df --- /dev/null +++ b/Flow.Launcher.Infrastructure/TranslationMapping.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace Flow.Launcher.Infrastructure +{ + public class TranslationMapping + { + private bool _isConstructed; + + // Assuming one original item maps to multi translated items + // list[i] is the last translated index + 1 of original index i + private readonly List _originalToTranslated = new(); + + public void AddNewIndex(int translatedIndex, int length) + { + if (_isConstructed) + throw new InvalidOperationException("Mapping shouldn't be changed after construction"); + _originalToTranslated.Add(translatedIndex + length); + } + + public int MapToOriginalIndex(int translatedIndex) + { + var searchResult = _originalToTranslated.BinarySearch(translatedIndex); + return searchResult >= 0 ? searchResult : ~searchResult; + } + + public void EndConstruct() + { + if (_isConstructed) + throw new InvalidOperationException("Mapping has already been constructed"); + _isConstructed = true; + } + } +} diff --git a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs b/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs deleted file mode 100644 index 350c892cf..000000000 --- a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Windows.Markup; - -namespace Flow.Launcher.Infrastructure.UI -{ - public class EnumBindingSourceExtension : MarkupExtension - { - private Type _enumType; - public Type EnumType - { - get { return _enumType; } - set - { - if (value != _enumType) - { - if (value != null) - { - Type enumType = Nullable.GetUnderlyingType(value) ?? value; - if (!enumType.IsEnum) - { - throw new ArgumentException("Type must represent an enum."); - } - } - - _enumType = value; - } - } - } - - public EnumBindingSourceExtension() { } - - public EnumBindingSourceExtension(Type enumType) - { - EnumType = enumType; - } - - public override object ProvideValue(IServiceProvider serviceProvider) - { - if (_enumType == null) - { - throw new InvalidOperationException("The EnumType must be specified."); - } - - Type actualEnumType = Nullable.GetUnderlyingType(_enumType) ?? _enumType; - Array enumValues = Enum.GetValues(actualEnumType); - - if (actualEnumType == _enumType) - { - return enumValues; - } - - Array tempArray = Array.CreateInstance(actualEnumType, enumValues.Length + 1); - enumValues.CopyTo(tempArray, 1); - return tempArray; - } - } -} diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs index 24584115d..9c795f952 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs @@ -1,11 +1,18 @@ +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Plugin; -using System.Text.Json.Serialization; namespace Flow.Launcher.Infrastructure.UserSettings { public class CustomBrowserViewModel : BaseModel { + // 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 string Name { get; set; } + [JsonIgnore] + public string DisplayName => Name == "Default" ? API.GetTranslation("defaultBrowser_default") : Name; public string Path { get; set; } public string PrivateArg { get; set; } public bool EnablePrivate { get; set; } @@ -26,8 +33,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings Editable = Editable }; } + + public void OnDisplayNameChanged() + { + OnPropertyChanged(nameof(DisplayName)); + } } } - - - diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs index c54c30478..2af0bb0e5 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs @@ -1,10 +1,18 @@ -using Flow.Launcher.Plugin; +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; -namespace Flow.Launcher.ViewModel +namespace Flow.Launcher.Infrastructure.UserSettings { public class CustomExplorerViewModel : BaseModel { + // 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 string Name { get; set; } + [JsonIgnore] + public string DisplayName => Name == "Explorer" ? API.GetTranslation("fileManagerExplorer") : Name; public string Path { get; set; } public string FileArgument { get; set; } = "\"%d\""; public string DirectoryArgument { get; set; } = "\"%d\""; @@ -21,5 +29,10 @@ namespace Flow.Launcher.ViewModel Editable = Editable }; } + + public void OnDisplayNameChanged() + { + OnPropertyChanged(nameof(DisplayName)); + } } } diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs index 71020369a..2603d4675 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,15 +1,17 @@ using System; using System.Text.Json.Serialization; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Plugin; 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 +24,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 +40,75 @@ 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 string LocalizedDescription => API.GetTranslation(Description); + + // 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 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/PluginHotkey.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginHotkey.cs index 9dc395aca..0c5c38028 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginHotkey.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginHotkey.cs @@ -1,4 +1,5 @@ -using Flow.Launcher.Plugin; +using System; +using Flow.Launcher.Plugin; namespace Flow.Launcher.Infrastructure.UserSettings { @@ -6,5 +7,26 @@ namespace Flow.Launcher.Infrastructure.UserSettings { public string Hotkey { get; set; } public string ActionKeyword { get; set; } + + public CustomPluginHotkey(string hotkey, string actionKeyword) + { + Hotkey = hotkey; + ActionKeyword = actionKeyword; + } + + public override bool Equals(object other) + { + if (other is CustomPluginHotkey otherHotkey) + { + return Hotkey == otherHotkey.Hotkey && ActionKeyword == otherHotkey.ActionKeyword; + } + + return false; + } + + public override int GetHashCode() + { + return HashCode.Combine(Hotkey, ActionKeyword); + } } } 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 4f16ae812..f5b71e197 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -1,24 +1,75 @@ -using System; -using System.Collections.Generic; +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; +using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; -using Flow.Launcher.ViewModel; namespace Flow.Launcher.Infrastructure.UserSettings { public class Settings : BaseModel, IHotkeySettings { - private string language = Constant.SystemLanguageCode; - private string _theme = Constant.DefaultTheme; + private FlowLauncherJsonStorage _storage; + private StringMatcher _stringMatcher = null; + + public void SetStorage(FlowLauncherJsonStorage storage) + { + _storage = storage; + } + + public void Initialize() + { + // Initialize dependency injection instances after Ioc.Default is created + _stringMatcher = Ioc.Default.GetRequiredService(); + + // Initialize application resources after application is created + var settingWindowFont = new FontFamily(SettingWindowFont); + Application.Current.Resources["SettingWindowFont"] = settingWindowFont; + Application.Current.Resources["ContentControlThemeFontFamily"] = settingWindowFont; + } + + public void Save() + { + _storage.Save(); + } + public string Hotkey { get; set; } = $"{KeyConstant.LeftAlt} + {KeyConstant.Space}"; - public string OpenResultModifiers { get; set; } = KeyConstant.Alt; + + private string _openResultModifiers = KeyConstant.Alt; + public string OpenResultModifiers + { + get => _openResultModifiers; + set + { + if (_openResultModifiers != value) + { + _openResultModifiers = value; + OnPropertyChanged(); + } + } + } + public string ColorScheme { get; set; } = "System"; - public bool ShowOpenResultHotkey { get; set; } = true; + + private bool _showOpenResultHotkey = true; + public bool ShowOpenResultHotkey + { + get => _showOpenResultHotkey; + set + { + if (_showOpenResultHotkey != value) + { + _showOpenResultHotkey = value; + OnPropertyChanged(); + } + } + } + public double WindowSize { get; set; } = 580; public string PreviewHotkey { get; set; } = $"F1"; public string AutoCompleteHotkey { get; set; } = $"{KeyConstant.Ctrl} + Tab"; @@ -31,47 +82,57 @@ 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"; + public string DialogJumpHotkey { get; set; } = $"{KeyConstant.Alt} + G"; + private string _language = Constant.SystemLanguageCode; public string Language { - get => language; + get => _language; set { - language = value; - OnPropertyChanged(); + if (_language != value) + { + _language = value; + OnPropertyChanged(); + } } } + private string _theme = Constant.DefaultTheme; public string Theme { get => _theme; set { - if (value == _theme) - return; - _theme = value; - OnPropertyChanged(); - OnPropertyChanged(nameof(MaxResultsToShow)); + if (_theme != value) + { + _theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(MaxResultsToShow)); + } } } public bool UseDropShadowEffect { get; set; } = true; + public BackdropTypes BackdropType { get; set; } = BackdropTypes.None; + public string ReleaseNotesVersion { get; set; } = string.Empty; /* 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; } @@ -79,6 +140,27 @@ 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(); + if (Application.Current != null) + { + 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; @@ -90,7 +172,68 @@ 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 bool AutoRestartAfterChanging { get; set; } = false; + public bool ShowUnknownSourceWarning { get; set; } = true; + public bool AutoUpdatePlugins { get; set; } = true; public int CustomExplorerIndex { get; set; } = 0; @@ -129,8 +272,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings new() { Name = "Files", - Path = "Files", - DirectoryArgument = "-select \"%d\"", + Path = "Files-Stable", + DirectoryArgument = "\"%d\"", FileArgument = "-select \"%f\"" } }; @@ -180,11 +323,70 @@ namespace Flow.Launcher.Infrastructure.UserSettings } }; + public bool EnableDialogJump { get; set; } = true; + + public bool AutoDialogJump { get; set; } = false; + + public bool ShowDialogJumpWindow { get; set; } = false; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpWindowPositions DialogJumpWindowPosition { get; set; } = DialogJumpWindowPositions.UnderDialog; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpResultBehaviours DialogJumpResultBehaviour { get; set; } = DialogJumpResultBehaviours.LeftClick; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpFileResultBehaviours DialogJumpFileResultBehaviour { get; set; } = DialogJumpFileResultBehaviours.FullPath; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public LOGLEVEL LogLevel { get; set; } = LOGLEVEL.INFO; /// /// when false Alphabet static service will always return empty results /// - public bool ShouldUsePinyin { get; set; } = false; + private bool _useAlphabet = true; + public bool ShouldUsePinyin + { + get => _useAlphabet; + set + { + if (_useAlphabet != value) + { + _useAlphabet = value; + OnPropertyChanged(); + } + } + } + + private bool _useDoublePinyin = false; + public bool UseDoublePinyin + { + get => _useDoublePinyin; + set + { + if (_useDoublePinyin != value) + { + _useDoublePinyin = value; + OnPropertyChanged(); + } + } + } + + private DoublePinyinSchemas _doublePinyinSchema = DoublePinyinSchemas.XiaoHe; + + [JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))] + public DoublePinyinSchemas DoublePinyinSchema + { + get => _doublePinyinSchema; + set + { + if (_doublePinyinSchema != value) + { + _doublePinyinSchema = value; + OnPropertyChanged(); + } + } + } public bool AlwaysPreview { get; set; } = false; @@ -197,9 +399,12 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _querySearchPrecision; set { - _querySearchPrecision = value; - if (StringMatcher.Instance != null) - StringMatcher.Instance.UserSettingSearchPrecision = value; + if (_querySearchPrecision != value) + { + _querySearchPrecision = value; + if (_stringMatcher != null) + _stringMatcher.UserSettingSearchPrecision = value; + } } } @@ -207,6 +412,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 @@ -218,19 +427,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) }; @@ -238,20 +463,41 @@ namespace Flow.Launcher.Infrastructure.UserSettings public bool EnableUpdateLog { get; set; } 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; - OnPropertyChanged(); + if (_hideNotifyIcon != value) + { + _hideNotifyIcon = value; + OnPropertyChanged(); + } } } public bool LeaveCmdOpen { get; set; } public bool HideWhenDeactivated { get; set; } = true; + private bool _showAtTopmost = false; + public bool ShowAtTopmost + { + get => _showAtTopmost; + set + { + if (_showAtTopmost != value) + { + _showAtTopmost = value; + OnPropertyChanged(); + } + } + } + + public bool SearchQueryResultsWithDelay { get; set; } + public int SearchDelayTime { get; set; } = 150; + [JsonConverter(typeof(JsonStringEnumConverter))] public SearchWindowScreens SearchWindowScreen { get; set; } = SearchWindowScreens.Cursor; @@ -274,7 +520,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings [JsonIgnore] public bool WMPInstalled { get; set; } = true; - // This needs to be loaded last by staying at the bottom public PluginsSettings PluginSettings { get; set; } = new PluginsSettings(); @@ -285,8 +530,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings { var list = FixedHotkeys(); - // Customizeable hotkeys - if(!string.IsNullOrEmpty(Hotkey)) + // Customizable hotkeys + if (!string.IsNullOrEmpty(Hotkey)) list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = "")); if (!string.IsNullOrEmpty(PreviewHotkey)) list.Add(new(PreviewHotkey, "previewHotkey", () => PreviewHotkey = "")); @@ -304,6 +549,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings list.Add(new(SelectPrevItemHotkey2, "SelectPrevItemHotkey", () => SelectPrevItemHotkey2 = "")); if (!string.IsNullOrEmpty(SettingWindowHotkey)) list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = "")); + if (!string.IsNullOrEmpty(OpenHistoryHotkey)) + list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = "")); if (!string.IsNullOrEmpty(OpenContextMenuHotkey)) list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = "")); if (!string.IsNullOrEmpty(SelectNextPageHotkey)) @@ -314,6 +561,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = "")); if (!string.IsNullOrEmpty(CycleHistoryDownHotkey)) list.Add(new(CycleHistoryDownHotkey, "CycleHistoryDownHotkey", () => CycleHistoryDownHotkey = "")); + if (!string.IsNullOrEmpty(DialogJumpHotkey)) + list.Add(new(DialogJumpHotkey, "dialogJumpHotkey", () => DialogJumpHotkey = "")); // Custom Query Hotkeys foreach (var customPluginHotkey in CustomPluginHotkeys) @@ -339,7 +588,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"), @@ -407,4 +655,44 @@ namespace Flow.Launcher.Infrastructure.UserSettings Fast, Custom } + + public enum BackdropTypes + { + None, + Acrylic, + Mica, + MicaAlt + } + + public enum DoublePinyinSchemas + { + XiaoHe, + ZiRanMa, + WeiRuan, + ZhiNengABC, + ZiGuangPinYin, + PinYinJiaJia, + XingKongJianDao, + DaNiu, + XiaoLang + } + + public enum DialogJumpWindowPositions + { + UnderDialog, + FollowDefault + } + + public enum DialogJumpResultBehaviours + { + LeftClick, + RightClick + } + + public enum DialogJumpFileResultBehaviours + { + FullPath, + FullPathOpen, + Directory + } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 867fef4f5..5d30b740d 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -1,7 +1,30 @@ 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 Flow.Launcher.Plugin.SharedModels; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.System.Threading; +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 +32,891 @@ 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 unsafe nint GetForegroundWindow() + { + return (nint)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)); + } + + public static bool IsForegroundWindow(nint handle) + { + return IsForegroundWindow(new HWND(handle)); + } + + 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 != HWND.Null) + { + return false; + } + } + + var monitorInfo = MonitorInfo.GetNearestDisplayMonitor(hWnd); + return (appBounds.bottom - appBounds.top) == monitorInfo.Bounds.Height && + (appBounds.right - appBounds.left) == monitorInfo.Bounds.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; + + public const int WM_POWERBROADCAST = (int)PInvoke.WM_POWERBROADCAST; + public const int PBT_APMRESUMEAUTOMATIC = (int)PInvoke.PBT_APMRESUMEAUTOMATIC; + + #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); + } + + internal static HWND GetMainWindowHandle() + { + // When application is exiting, the Application.Current will be null + if (Application.Current == null) return HWND.Null; + + // Get the FL main window + var hwnd = GetWindowHandle(Application.Current.MainWindow, true); + return hwnd; + } + + #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 unsafe 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, + new LPARAM((nint)_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 Window Rect + + public static unsafe bool GetWindowRect(nint handle, out Rect outRect) + { + var rect = new RECT(); + var result = PInvoke.GetWindowRect(new(handle), &rect); + if (!result) + { + outRect = new Rect(); + return false; + } + + // Convert RECT to Rect + outRect = new Rect( + rect.left, + rect.top, + rect.right - rect.left, + rect.bottom - rect.top + ); + return true; + } + + #endregion + + #region Window Process + + internal static unsafe string GetProcessNameFromHwnd(HWND hWnd) + { + return Path.GetFileName(GetProcessPathFromHwnd(hWnd)); + } + + internal static unsafe string GetProcessPathFromHwnd(HWND hWnd) + { + uint pid; + var threadId = PInvoke.GetWindowThreadProcessId(hWnd, &pid); + if (threadId == 0) return string.Empty; + + var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (process != HWND.Null) + { + using var safeHandle = new SafeProcessHandle((nint)process.Value, true); + uint capacity = 2000; + Span buffer = new char[capacity]; + if (!PInvoke.QueryFullProcessImageName(safeHandle, PROCESS_NAME_FORMAT.PROCESS_NAME_WIN32, buffer, ref capacity)) + { + return string.Empty; + } + + return buffer[..(int)capacity].ToString(); + } + + return string.Empty; + } + + #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 + + #region Win32 Dark Mode + + /* + * Inspired by https://github.com/ysc3839/win32-darkmode + */ + + [DllImport("uxtheme.dll", EntryPoint = "#135", SetLastError = true)] + private static extern int SetPreferredAppMode(int appMode); + + public static void EnableWin32DarkMode(string colorScheme) + { + try + { + // Undocumented API from Windows 10 1809 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.Build >= 17763) + { + var flag = colorScheme switch + { + Constant.Light => 3, // ForceLight + Constant.Dark => 2, // ForceDark + Constant.System => 1, // AllowDark + _ => 0 // Default + }; + _ = SetPreferredAppMode(flag); + } + + } + catch + { + // Ignore errors on unsupported OS + } + } + + #endregion + + #region File / Folder Dialog + + public static string SelectFile() + { + var dlg = new OpenFileDialog(); + var result = dlg.ShowDialog(); + if (result == true) + return dlg.FileName; + + return string.Empty; + } + #endregion } } diff --git a/Flow.Launcher.Infrastructure/packages.lock.json b/Flow.Launcher.Infrastructure/packages.lock.json new file mode 100644 index 000000000..47c94d5f6 --- /dev/null +++ b/Flow.Launcher.Infrastructure/packages.lock.json @@ -0,0 +1,198 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "Ben.Demystifier": { + "type": "Direct", + "requested": "[0.4.1, )", + "resolved": "0.4.1", + "contentHash": "axFeEMfmEORy3ipAzOXG/lE+KcNptRbei3F0C4kQCdeiQtW+qJW90K5iIovITGrdLt8AjhNCwk5qLSX9/rFpoA==", + "dependencies": { + "System.Reflection.Metadata": "5.0.0" + } + }, + "BitFaster.Caching": { + "type": "Direct", + "requested": "[2.5.4, )", + "resolved": "2.5.4", + "contentHash": "1QroTY1PVCZOSG9FnkkCrmCKk/+bZCgI/YXq376HnYwUDJ4Ho0EaV4YaA/5v5WYLnwIwIO7RZkdWbg9pxIpueQ==" + }, + "CommunityToolkit.Mvvm": { + "type": "Direct", + "requested": "[8.4.0, )", + "resolved": "8.4.0", + "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw==" + }, + "Fody": { + "type": "Direct", + "requested": "[6.9.3, )", + "resolved": "6.9.3", + "contentHash": "1CUGgFdyECDKgi5HaUBhdv6k+VG9Iy4OCforGfHyar3xQXAJypZkzymgKtWj/4SPd6nSG0Qi7NH71qHrDSZLaA==" + }, + "InputSimulator": { + "type": "Direct", + "requested": "[1.0.4, )", + "resolved": "1.0.4", + "contentHash": "D0LvRCPQMX6/FJHBjng+RO+wRDuHTJrfo7IAc7rmkPvRqchdVGJWg3y70peOtDy3OLNK+HSOwVkH4GiuLnkKgA==" + }, + "MemoryPack": { + "type": "Direct", + "requested": "[1.21.4, )", + "resolved": "1.21.4", + "contentHash": "wy3JTBNBsO8LfQcBvvYsWr3lm2Oakolrfu0UQ3oSJSEiD+7ye0GUhYTaXuYYBowqsXBXWD9gf2218ae0JRiYVQ==", + "dependencies": { + "MemoryPack.Core": "1.21.4", + "MemoryPack.Generator": "1.21.4" + } + }, + "Microsoft.VisualStudio.Threading": { + "type": "Direct", + "requested": "[17.14.15, )", + "resolved": "17.14.15", + "contentHash": "1DrCusT3xNLSlaJg77BsUSAzrhjdZBAvvsS0PMzyPM+fGais6SnISOhqdZQop8VVMIBLsYm2gyF9W7THjgavwA==", + "dependencies": { + "Microsoft.VisualStudio.Threading.Analyzers": "17.14.15", + "Microsoft.VisualStudio.Threading.Only": "17.14.15", + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Microsoft.Windows.CsWin32": { + "type": "Direct", + "requested": "[0.3.205, )", + "resolved": "0.3.205", + "contentHash": "U5wGAnyKd7/I2YMd43nogm81VMtjiKzZ9dsLMVI4eAB7jtv5IEj0gprj0q/F3iRmAIaGv5omOf8iSYx2+nE6BQ==", + "dependencies": { + "Microsoft.Windows.SDK.Win32Docs": "0.1.42-alpha", + "Microsoft.Windows.SDK.Win32Metadata": "61.0.15-preview", + "Microsoft.Windows.WDK.Win32Metadata": "0.12.8-experimental" + } + }, + "NHotkey.Wpf": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "BIUKlhTG5KtFf9OQzWvkmVmktt5/FFj6AOEgag8Uf0R2YdZt5ajUzs3sVskcJcT2TztWlEHKQr1jFj3KQ0D9Nw==", + "dependencies": { + "NHotkey": "3.0.0" + } + }, + "NLog": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "Xr+lIk1ZlTTFXEqnxQVLxrDqZlt2tm5X+/AhJbaY2emb/dVtGDiU5QuEtj3gHtwV/SWlP/rJ922I/BPuOJXlRw==" + }, + "NLog.OutputDebugString": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "TOP2Ap9BbE98B/l/TglnguowOD0rXo8B/20xAgvj9shO/kf6IJ5M4QMhVxq72mrneJ/ANhHY7Jcd+xJbzuI5PA==", + "dependencies": { + "NLog": "6.0.4" + } + }, + "PropertyChanged.Fody": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "6v+f9cI8YjnZH2WBHuOqWEAo8DFFNGFIdU8xD3AsL6fhV6Y8oAmVWd7XKk49t8DpeUBwhR/X+97+6Epvek0Y3A==", + "dependencies": { + "Fody": "6.6.4" + } + }, + "SharpVectors.Wpf": { + "type": "Direct", + "requested": "[1.8.5, )", + "resolved": "1.8.5", + "contentHash": "WURdBDq5AE8RjKV9pFS7lNkJe81gxja9SaMGE4URq9GJUZ6M+5DGUL0Lm3B0iYW2/Meyowaz4ffGsyW+RBSTtg==" + }, + "System.Drawing.Common": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "KIX+oBU38pxkKPxvLcLfIkOV5Ien8ReN78wro7OF5/erwcmortzeFx+iBswlh2Vz6gVne0khocQudGwaO1Ey6A==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "7.0.0" + } + }, + "ToolGood.Words.Pinyin": { + "type": "Direct", + "requested": "[3.1.0.3, )", + "resolved": "3.1.0.3", + "contentHash": "VKcf8sUq/+LyY99WgLhOu7Q32ROEyR30/2xCCj9ADRi45wVC7kpXrYCf9vH1qirkmrIfpL8inoxAbrqAlfXxsQ==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2025.2.2", + "contentHash": "0X56ZRizuHdrnPpgXjWV7f2tQO1FlQg5O1967OGKnI/4ZRNOK642J8L7brM1nYvrxTTU5TP1yRyXLRLaXLPQ8A==" + }, + "MemoryPack.Core": { + "type": "Transitive", + "resolved": "1.21.4", + "contentHash": "6RszGorZ0ejNmp37ZcboPBMvvPCuNW2jlrdQfcs/lMzE5b3pmPF6hsm/laDc34hRlbAST1ZxaX/DvYu2DF5sBQ==" + }, + "MemoryPack.Generator": { + "type": "Transitive", + "resolved": "1.21.4", + "contentHash": "g14EsSS85yn0lHTi0J9ivqlZMf09A2iI51fmI+0KkzIzyCbWOBWPi5mdaY7YWmXprk12aYh9u/qfWHQUYthlwg==" + }, + "Microsoft.VisualStudio.Threading.Analyzers": { + "type": "Transitive", + "resolved": "17.14.15", + "contentHash": "mXQPJsbuUD2ydq4/ffd8h8tSOFCXec+2xJOVNCvXjuMOq/+5EKHq3D2m2MC2+nUaXeFMSt66VS/J4HdKBixgcw==" + }, + "Microsoft.VisualStudio.Threading.Only": { + "type": "Transitive", + "resolved": "17.14.15", + "contentHash": "NqONyw1RXyj9P3k5e1uU2k9kc1ptwuU5NJQzG+MPq7vQVHUzBY8HLuJf/N2Rw5H/myD96CVxziDxmjawPuzntw==", + "dependencies": { + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "17.8.8", + "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "2nXPrhdAyAzir0gLl8Yy8S5Mnm/uBSQQA7jEsILOS1MTyS7DbmV1NgViMtvV1sfCD1ebITpNwb1NIinKeJgUVQ==" + }, + "Microsoft.Windows.SDK.Win32Docs": { + "type": "Transitive", + "resolved": "0.1.42-alpha", + "contentHash": "Z/9po23gUA9aoukirh2ItMU2ZS9++Js9Gdds9fu5yuMojDrmArvY2y+tq9985tR3cxFxpZO1O35Wjfo0khj5HA==" + }, + "Microsoft.Windows.SDK.Win32Metadata": { + "type": "Transitive", + "resolved": "61.0.15-preview", + "contentHash": "cysex3dazKtCPALCluC2XX3f5Aedy9H2pw5jb+TW5uas2rkem1Z7FRnbUrg2vKx0pk0Qz+4EJNr37HdYTEcvEQ==" + }, + "Microsoft.Windows.WDK.Win32Metadata": { + "type": "Transitive", + "resolved": "0.12.8-experimental", + "contentHash": "3n8R44/Z96Ly+ty4eYVJfESqbzvpw96lRLs3zOzyDmr1x1Kw7FNn5CyE416q+bZQV3e1HRuMUvyegMeRE/WedA==", + "dependencies": { + "Microsoft.Windows.SDK.Win32Metadata": "61.0.15-preview" + } + }, + "NHotkey": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "IEghs0QqWsQYH0uUmvIl0Ye6RaebWRh38eB6ToOkDnQucTYRGFOgtig0gSxlwCszTilYFz3n1ZuY762x+kDR3A==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5NecZgXktdGg34rh1OenY1rFNDCI8xSjFr+Z4OU4cU06AQHUdRnIIEeWENu3Wl4YowbzkymAIMvi3WyK9U53pQ==" + }, + "flow.launcher.plugin": { + "type": "Project", + "dependencies": { + "JetBrains.Annotations": "[2025.2.2, )" + } + } + } + } +} \ No newline at end of file diff --git a/Flow.Launcher.Plugin/ActionContext.cs b/Flow.Launcher.Plugin/ActionContext.cs index a38907a9d..7ec91285d 100644 --- a/Flow.Launcher.Plugin/ActionContext.cs +++ b/Flow.Launcher.Plugin/ActionContext.cs @@ -59,6 +59,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/DialogJumpResult.cs b/Flow.Launcher.Plugin/DialogJumpResult.cs new file mode 100644 index 000000000..2c9f0c139 --- /dev/null +++ b/Flow.Launcher.Plugin/DialogJumpResult.cs @@ -0,0 +1,92 @@ +namespace Flow.Launcher.Plugin +{ + /// + /// Describes a result of a executed by a plugin in Dialog Jump window + /// + public class DialogJumpResult : Result + { + /// + /// This holds the path which can be provided by plugin to be navigated to the + /// file dialog when records in Dialog Jump window is right clicked on a result. + /// + public required string DialogJumpPath { get; init; } + + /// + /// Clones the current Dialog Jump result + /// + public new DialogJumpResult Clone() + { + return new DialogJumpResult + { + 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, + DialogJumpPath = DialogJumpPath + }; + } + + /// + /// Convert to . + /// + public static DialogJumpResult From(Result result, string dialogJumpPath) + { + return new DialogJumpResult + { + Title = result.Title, + SubTitle = result.SubTitle, + ActionKeywordAssigned = result.ActionKeywordAssigned, + CopyText = result.CopyText, + AutoCompleteText = result.AutoCompleteText, + IcoPath = result.IcoPath, + BadgeIcoPath = result.BadgeIcoPath, + RoundedIcon = result.RoundedIcon, + Icon = result.Icon, + BadgeIcon = result.BadgeIcon, + Glyph = result.Glyph, + Action = result.Action, + AsyncAction = result.AsyncAction, + Score = result.Score, + TitleHighlightData = result.TitleHighlightData, + OriginQuery = result.OriginQuery, + PluginDirectory = result.PluginDirectory, + ContextData = result.ContextData, + PluginID = result.PluginID, + TitleToolTip = result.TitleToolTip, + SubTitleToolTip = result.SubTitleToolTip, + PreviewPanel = result.PreviewPanel, + ProgressBar = result.ProgressBar, + ProgressBarColor = result.ProgressBarColor, + Preview = result.Preview, + AddSelectedCount = result.AddSelectedCount, + RecordKey = result.RecordKey, + ShowBadge = result.ShowBadge, + DialogJumpPath = dialogJumpPath + }; + } + } +} diff --git a/Flow.Launcher.Plugin/EventHandler.cs b/Flow.Launcher.Plugin/EventHandler.cs index 893b0ba80..47ab24757 100644 --- a/Flow.Launcher.Plugin/EventHandler.cs +++ b/Flow.Launcher.Plugin/EventHandler.cs @@ -39,7 +39,14 @@ namespace Flow.Launcher.Plugin /// /// public delegate void VisibilityChangedEventHandler(object sender, VisibilityChangedEventArgs args); - + + /// + /// A delegate for when the actual application theme is changed + /// + /// + /// + public delegate void ActualApplicationThemeChangedEventHandler(object sender, ActualApplicationThemeChangedEventArgs args); + /// /// The event args for /// @@ -77,4 +84,15 @@ namespace Flow.Launcher.Plugin /// public Query Query { get; set; } } + + /// + /// The event args for + /// + public class ActualApplicationThemeChangedEventArgs : EventArgs + { + /// + /// if the application has changed actual theme + /// + public bool IsDark { get; init; } + } } diff --git a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj index 2feb21b12..1ae0b1f58 100644 --- a/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj +++ b/Flow.Launcher.Plugin/Flow.Launcher.Plugin.csproj @@ -1,7 +1,7 @@ - + - net7.0-windows + net9.0-windows {8451ECDD-2EA4-4966-BB0A-7BBC40138E80} true Library @@ -11,13 +11,14 @@ false false false + true - 4.4.0 - 4.4.0 - 4.4.0 - 4.4.0 + 5.0.0 + 5.0.0 + 5.0.0 + 5.0.0 Flow.Launcher.Plugin Flow-Launcher MIT @@ -27,6 +28,7 @@ true true Readme.md + true @@ -66,17 +68,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs new file mode 100644 index 000000000..e028ebb12 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Dialog Jump Model + /// + public interface IAsyncDialogJump : IFeatures + { + /// + /// Asynchronous querying for Dialog Jump window + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncDialogJump interface + /// + /// Query to search + /// Cancel when querying job is obsolete + /// + Task> QueryDialogJumpAsync(Query query, CancellationToken token); + } +} 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/IDialogJump.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJump.cs new file mode 100644 index 000000000..d81b2bd19 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJump.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Dialog Jump Model + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncDialogJump interface + /// + /// + public interface IDialogJump : IAsyncDialogJump + { + /// + /// Querying for Dialog Jump window + /// + /// This method will be called within a Task.Run, + /// so please avoid synchrously wait for long. + /// + /// + /// Query to search + /// + List QueryDialogJump(Query query); + + Task> IAsyncDialogJump.QueryDialogJumpAsync(Query query, CancellationToken token) => Task.Run(() => QueryDialogJump(query), token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs new file mode 100644 index 000000000..33ad9ae73 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs @@ -0,0 +1,96 @@ +using System; + +#nullable enable + +namespace Flow.Launcher.Plugin +{ + /// + /// Interface for handling file dialog instances in DialogJump. + /// + public interface IDialogJumpDialog : IFeatures, IDisposable + { + /// + /// Check if the foreground window is a file dialog instance. + /// + /// + /// The handle of the foreground window to check. + /// + /// + /// The window if the foreground window is a file dialog instance. Null if it is not. + /// + IDialogJumpDialogWindow? CheckDialogWindow(IntPtr hwnd); + } + + /// + /// Interface for handling a specific file dialog window in DialogJump. + /// + public interface IDialogJumpDialogWindow : IDisposable + { + /// + /// The handle of the dialog window. + /// + IntPtr Handle { get; } + + /// + /// Get the current tab of the dialog window. + /// + /// + IDialogJumpDialogWindowTab GetCurrentTab(); + } + + /// + /// Interface for handling a specific tab in a file dialog window in DialogJump. + /// + public interface IDialogJumpDialogWindowTab : IDisposable + { + /// + /// The handle of the dialog tab. + /// + IntPtr Handle { get; } + + /// + /// Get the current folder path of the dialog tab. + /// + /// + string GetCurrentFolder(); + + /// + /// Get the current file of the dialog tab. + /// + /// + string GetCurrentFile(); + + /// + /// Jump to a folder in the dialog tab. + /// + /// + /// The path to the folder to jump to. + /// + /// + /// Whether folder jump is under automatical mode. + /// + /// + /// True if the jump was successful, false otherwise. + /// + bool JumpFolder(string path, bool auto); + + /// + /// Jump to a file in the dialog tab. + /// + /// + /// The path to the file to jump to. + /// + /// + /// True if the jump was successful, false otherwise. + /// + bool JumpFile(string path); + + /// + /// Open the file in the dialog tab. + /// + /// + /// True if the file was opened successfully, false otherwise. + /// + bool Open(); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs new file mode 100644 index 000000000..9a2b879d0 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs @@ -0,0 +1,40 @@ +using System; + +#nullable enable + +namespace Flow.Launcher.Plugin +{ + /// + /// Interface for handling file explorer instances in DialogJump. + /// + public interface IDialogJumpExplorer : IFeatures, IDisposable + { + /// + /// Check if the foreground window is a Windows Explorer instance. + /// + /// + /// The handle of the foreground window to check. + /// + /// + /// The window if the foreground window is a file explorer instance. Null if it is not. + /// + IDialogJumpExplorerWindow? CheckExplorerWindow(IntPtr hwnd); + } + + /// + /// Interface for handling a specific file explorer window in DialogJump. + /// + public interface IDialogJumpExplorerWindow : IDisposable + { + /// + /// The handle of the explorer window. + /// + IntPtr Handle { get; } + + /// + /// Get the current folder path of the explorer window. + /// + /// + string? GetExplorerPath(); + } +} 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/IPlugin.cs b/Flow.Launcher.Plugin/Interfaces/IPlugin.cs index bac93d090..cf5a8a582 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPlugin.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPlugin.cs @@ -32,6 +32,6 @@ namespace Flow.Launcher.Plugin Task IAsyncPlugin.InitAsync(PluginInitContext context) => Task.Run(() => Init(context)); - Task> IAsyncPlugin.QueryAsync(Query query, CancellationToken token) => Task.Run(() => Query(query)); + Task> IAsyncPlugin.QueryAsync(Query query, CancellationToken token) => Task.Run(() => Query(query), token); } } diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 8376fd07b..dd44647b4 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -1,5 +1,3 @@ -using Flow.Launcher.Plugin.SharedModels; -using JetBrains.Annotations; using System; using System.Collections.Generic; using System.ComponentModel; @@ -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 { @@ -17,12 +18,13 @@ namespace Flow.Launcher.Plugin public interface IPublicAPI { /// - /// Change Flow.Launcher query + /// Change Flow.Launcher query. + /// When current results are from context menu or history, it will go back to query results before changing query. /// /// query text /// - /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. - /// Set this to to force Flow Launcher requerying + /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. + /// Set this to to force Flow Launcher re-querying /// void ChangeQuery(string query, bool requery = false); @@ -47,7 +49,7 @@ namespace Flow.Launcher.Plugin /// /// Text to save on clipboard /// When true it will directly copy the file/folder from the path specified in text - /// Whether to show the default notification from this method after copy is done. + /// Whether to show the default notification from this method after copy is done. /// It will show file/folder/text is copied successfully. /// Turn this off to show your own notification after copy is done.> public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true); @@ -63,7 +65,7 @@ namespace Flow.Launcher.Plugin void SavePluginSettings(); /// - /// Reloads any Plugins that have the + /// Reloads any Plugins that have the /// IReloadable implemented. It refeshes /// Plugin's in memory data with new content /// added by user. @@ -82,11 +84,25 @@ namespace Flow.Launcher.Plugin /// Optional message subtitle void ShowMsgError(string title, string subTitle = ""); + /// + /// Show the error message using Flow's standard error icon. + /// + /// Message title + /// Message button content + /// Message button action + /// Optional message subtitle + void ShowMsgErrorWithButton(string title, string buttonText, Action buttonAction, string subTitle = ""); + /// /// Show the MainWindow when hiding /// void ShowMainWindow(); + /// + /// Focus the query text box in the main window + /// + void FocusQueryTextBox(); + /// /// Hide MainWindow /// @@ -99,7 +115,7 @@ namespace Flow.Launcher.Plugin bool IsMainWindowVisible(); /// - /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. + /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. /// event VisibilityChangedEventHandler VisibilityChanged; @@ -120,6 +136,27 @@ namespace Flow.Launcher.Plugin /// when true will use main windows as the owner void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true); + /// + /// Show message box with button + /// + /// Message title + /// Message button content + /// Message button action + /// Message subtitle + /// Message icon path (relative path to your plugin folder) + void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = ""); + + /// + /// Show message box with button + /// + /// Message title + /// Message button content + /// Message button action + /// Message subtitle + /// Message icon path (relative path to your plugin folder) + /// when true will use main windows as the owner + void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true); + /// /// Open setting dialog /// @@ -134,24 +171,55 @@ namespace Flow.Launcher.Plugin string GetTranslation(string key); /// - /// Get all loaded plugins + /// Get all loaded plugins /// /// 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 /// @@ -161,7 +229,7 @@ namespace Flow.Launcher.Plugin MatchResult FuzzySearch(string query, string stringToCompare); /// - /// Http download the spefic url and return as string + /// Http download the specific url and return as string /// /// URL to call Http Get /// Cancellation Token @@ -169,7 +237,7 @@ namespace Flow.Launcher.Plugin Task HttpGetStringAsync(string url, CancellationToken token = default); /// - /// Http download the spefic url and return as stream + /// Http download the specific url and return as stream /// /// URL to call Http Get /// Cancellation Token @@ -190,14 +258,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,8 +300,13 @@ namespace Flow.Launcher.Plugin void LogWarn(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 + /// 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 /// void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = ""); @@ -241,9 +319,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 /// @@ -257,13 +336,28 @@ namespace Flow.Launcher.Plugin public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null); /// - /// Opens the URL with the given Uri object. + /// Opens the URL using the browser with the given Uri object, even if the URL is a local file. + /// The browser and mode used is based on what's configured in Flow's default browser settings. + /// + public void OpenWebUrl(Uri url, bool? inPrivate = null); + + /// + /// Opens the URL using the browser with the given string, even if the URL is a local file. + /// The browser and mode used is based on what's configured in Flow's default browser settings. + /// Non-C# plugins should use this method. + /// + public void OpenWebUrl(string url, bool? inPrivate = null); + + /// + /// Opens the URL with the given Uri object in browser if scheme is Http or Https. + /// If the URL is a local file, it will instead be opened with the default application for that file type. /// The browser and mode used is based on what's configured in Flow's default browser settings. /// public void OpenUrl(Uri url, bool? inPrivate = null); /// - /// Opens the URL with the given string. + /// Opens the URL with the given string in browser if scheme is Http or Https. + /// If the URL is a local file, it will instead be opened with the default application for that file type. /// The browser and mode used is based on what's configured in Flow's default browser settings. /// Non-C# plugins should use this method. /// @@ -299,7 +393,7 @@ namespace Flow.Launcher.Plugin /// /// Reloads the query. - /// This method should run when selected item is from query results. + /// When current results are from context menu or history, it will go back to query results before re-querying. /// /// Choose the first result after reload if true; keep the last selected result if false. Default is true. public void ReQuery(bool reselect = true); @@ -322,17 +416,214 @@ namespace Flow.Launcher.Plugin public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK); /// - /// Displays a standardised Flow message box. - /// If there is issue when showing the message box, it will return null. + /// Displays a standardised Flow progress box. /// - /// The caption of the message box. + /// The caption of the progress box. /// /// Time-consuming task function, whose input is the action to report progress. /// The input of the action is the progress value which is a double value between 0 and 100. /// If there are any exceptions, this action will be null. /// - /// When user closes the progress box manually by button or esc key, this action will be called. - /// A progress box interface. - public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action forceClosed = null); + /// When user cancel the progress, this action will be called. + /// + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action cancelProgress = null); + + /// + /// Start the loading bar in main window + /// + public void StartLoadingBar(); + + /// + /// 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. + /// + /// + /// True if the plugin is updated successfully, false otherwise. + /// + 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. + /// + /// + /// True if the plugin is installed successfully, false otherwise. + /// + public bool 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. + /// + /// + /// True if the plugin is updated successfully, false otherwise. + /// + 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 = ""); + + /// + /// Representing whether the application is using a dark theme + /// + /// + bool IsApplicationDarkTheme(); + + /// + /// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off. + /// + event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged; + + /// + /// Get the user data directory of Flow Launcher. + /// + /// + string GetDataDirectory(); + + /// + /// Get the log directory of Flow Launcher. + /// + /// + string GetLogDirectory(); } } 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..901695a8b 100644 --- a/Flow.Launcher.Plugin/NativeMethods.txt +++ b/Flow.Launcher.Plugin/NativeMethods.txt @@ -1,3 +1,16 @@ EnumThreadWindows GetWindowText -GetWindowTextLength \ No newline at end of file +GetWindowTextLength + +WM_KEYDOWN +WM_KEYUP +WM_SYSKEYDOWN +WM_SYSKEYUP + +GetSystemMetrics +EnumDisplayMonitors +MonitorFromWindow +GetMonitorInfo +MONITORINFOEXW +GetCursorPos +MonitorFromPoint \ 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 bb005752e..2c9b8d4fd 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. /// @@ -52,7 +57,10 @@ namespace Flow.Launcher.Plugin /// for user on the plugin result. If autocomplete action for example is tab, pressing tab will have /// the default constructed autocomplete text (result's Title), or the text provided here if not empty. /// - /// When a value is not set, the will be used. + /// + /// When a value is not set, the will be used. + /// Please include the action keyword prefix when necessary because Flow does not prepend it automatically. + /// public string AutoCompleteText { get; set; } /// @@ -62,7 +70,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 +90,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 +131,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 +184,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 +225,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,17 +246,85 @@ 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. /// If the plugin does not specific this, FL just uses Title and SubTitle to identify this result. + /// Note: Because old data does not have this key, we should use null as the default value for consistency. /// - public string RecordKey { get; set; } = string.Empty; + 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; + + /// + /// This holds the text which can be shown as a query suggestion. + /// + /// + /// When a value is not set, the will be used. + /// Do not include the action keyword prefix because Flow prepends it automatically. + /// If the it does not start with the query text, it will not be shown as a suggestion. + /// So make sure to set this value to start with the query text. + /// + public string QuerySuggestionText { get; set; } + + /// + /// 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, + QuerySuggestionText = QuerySuggestionText + }; + } /// /// 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/MonitorInfo.cs b/Flow.Launcher.Plugin/SharedModels/MonitorInfo.cs new file mode 100644 index 000000000..6808c9c91 --- /dev/null +++ b/Flow.Launcher.Plugin/SharedModels/MonitorInfo.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +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.Plugin.SharedModels; + +/// +/// Contains full information about a display monitor. +/// Inspired from: https://github.com/Jack251970/DesktopWidgets3. +/// +/// +/// Use this class to replace the System.Windows.Forms.Screen class which can cause possible System.PlatformNotSupportedException. +/// +public 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((monitor, deviceContext, rect, data) => + { + list.Add(new MonitorInfo(monitor, rect)); + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, 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(nint hwnd) + { + var nearestMonitor = PInvoke.MonitorFromWindow(new(hwnd), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + MonitorInfo nearestMonitorInfo = null; + var callback = new MONITORENUMPROC((monitor, deviceContext, rect, 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, null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return nearestMonitorInfo; + } + + /// + /// Gets the primary display monitor (the one that contains the taskbar). + /// + /// The primary display monitor, or null if no monitor is found. + public static unsafe MonitorInfo GetPrimaryDisplayMonitor() + { + var primaryMonitor = PInvoke.MonitorFromWindow(new HWND(nint.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); + MonitorInfo primaryMonitorInfo = null; + var callback = new MONITORENUMPROC((monitor, deviceContext, rect, data) => + { + if (monitor == primaryMonitor) + { + primaryMonitorInfo = new MonitorInfo(monitor, rect); + return false; + } + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return primaryMonitorInfo; + } + + /// + /// Gets the display monitor that contains the cursor. + /// + /// The display monitor that contains the cursor, or null if no monitor is found. + public static unsafe MonitorInfo GetCursorDisplayMonitor() + { + if (!PInvoke.GetCursorPos(out var pt)) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + var cursorMonitor = PInvoke.MonitorFromPoint(pt, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + MonitorInfo cursorMonitorInfo = null; + var callback = new MONITORENUMPROC((monitor, deviceContext, rect, data) => + { + if (monitor == cursorMonitor) + { + cursorMonitorInfo = new MonitorInfo(monitor, rect); + return false; + } + return true; + }); + var dwData = new LPARAM(); + var hdc = new HDC(); + bool ok = PInvoke.EnumDisplayMonitors(hdc, null, callback, dwData); + if (!ok) + { + Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); + } + return cursorMonitorInfo; + } + + private readonly HMONITOR _monitor; + + internal unsafe MonitorInfo(HMONITOR monitor, RECT* rect) + { + Bounds = + 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); + WorkingArea = + 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 Bounds { 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 WorkingArea { get; } + + /// + /// Gets if the monitor is the primary display monitor. + /// + public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(nint.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); + + /// + public override string ToString() => $"{Name} {Bounds.Width}x{Bounds.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.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..4257a8b2e --- /dev/null +++ b/Flow.Launcher.Plugin/UserPlugin.cs @@ -0,0 +1,85 @@ +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); + + /// + /// The minimum Flow Launcher version required for this plugin. Default is "". + /// + public string MinimumAppVersion { get; set; } = string.Empty; + } +} diff --git a/Flow.Launcher.Plugin/packages.lock.json b/Flow.Launcher.Plugin/packages.lock.json new file mode 100644 index 000000000..70f71f20d --- /dev/null +++ b/Flow.Launcher.Plugin/packages.lock.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "Fody": { + "type": "Direct", + "requested": "[6.9.3, )", + "resolved": "6.9.3", + "contentHash": "1CUGgFdyECDKgi5HaUBhdv6k+VG9Iy4OCforGfHyar3xQXAJypZkzymgKtWj/4SPd6nSG0Qi7NH71qHrDSZLaA==" + }, + "JetBrains.Annotations": { + "type": "Direct", + "requested": "[2025.2.2, )", + "resolved": "2025.2.2", + "contentHash": "0X56ZRizuHdrnPpgXjWV7f2tQO1FlQg5O1967OGKnI/4ZRNOK642J8L7brM1nYvrxTTU5TP1yRyXLRLaXLPQ8A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Windows.CsWin32": { + "type": "Direct", + "requested": "[0.3.205, )", + "resolved": "0.3.205", + "contentHash": "U5wGAnyKd7/I2YMd43nogm81VMtjiKzZ9dsLMVI4eAB7jtv5IEj0gprj0q/F3iRmAIaGv5omOf8iSYx2+nE6BQ==", + "dependencies": { + "Microsoft.Windows.SDK.Win32Docs": "0.1.42-alpha", + "Microsoft.Windows.SDK.Win32Metadata": "61.0.15-preview", + "Microsoft.Windows.WDK.Win32Metadata": "0.12.8-experimental" + } + }, + "PropertyChanged.Fody": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "6v+f9cI8YjnZH2WBHuOqWEAo8DFFNGFIdU8xD3AsL6fhV6Y8oAmVWd7XKk49t8DpeUBwhR/X+97+6Epvek0Y3A==", + "dependencies": { + "Fody": "6.6.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "Microsoft.Windows.SDK.Win32Docs": { + "type": "Transitive", + "resolved": "0.1.42-alpha", + "contentHash": "Z/9po23gUA9aoukirh2ItMU2ZS9++Js9Gdds9fu5yuMojDrmArvY2y+tq9985tR3cxFxpZO1O35Wjfo0khj5HA==" + }, + "Microsoft.Windows.SDK.Win32Metadata": { + "type": "Transitive", + "resolved": "61.0.15-preview", + "contentHash": "cysex3dazKtCPALCluC2XX3f5Aedy9H2pw5jb+TW5uas2rkem1Z7FRnbUrg2vKx0pk0Qz+4EJNr37HdYTEcvEQ==" + }, + "Microsoft.Windows.WDK.Win32Metadata": { + "type": "Transitive", + "resolved": "0.12.8-experimental", + "contentHash": "3n8R44/Z96Ly+ty4eYVJfESqbzvpw96lRLs3zOzyDmr1x1Kw7FNn5CyE416q+bZQV3e1HRuMUvyegMeRE/WedA==", + "dependencies": { + "Microsoft.Windows.SDK.Win32Metadata": "61.0.15-preview" + } + } + } + } +} \ No newline at end of file diff --git a/Flow.Launcher.Test/ChineseDetectionPerformanceTest.cs b/Flow.Launcher.Test/ChineseDetectionPerformanceTest.cs new file mode 100644 index 000000000..1747f2b4a --- /dev/null +++ b/Flow.Launcher.Test/ChineseDetectionPerformanceTest.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NUnit.Framework; +using NUnit.Framework.Legacy; +using Flow.Launcher.Infrastructure; +using ToolGood.Words.Pinyin; + +namespace Flow.Launcher.Test +{ + /// + /// Performance test comparing ContainsChinese() vs WordsHelper.HasChinese() + /// + /// This test verifies: + /// 1. Both methods produce identical results (correctness) + /// 2. Performance characteristics of both implementations + /// 3. Memory allocation patterns + /// + /// The ContainsChinese() method uses optimized Unicode range checking with ReadOnlySpan + /// while WordsHelper.HasChinese() uses the ToolGood.Words library implementation. + /// + [TestFixture] + public class ChineseDetectionPerformanceTest + { + private readonly List _testStrings = new() + { + // Pure English - should return false + "Hello World", + "Visual Studio Code", + "Microsoft Office 2023", + "Adobe Photoshop Creative Suite", + "Google Chrome Browser Application", + + // Pure Chinese - should return true + "你好世界", + "微软办公软件", + "谷歌浏览器", + "北京大学计算机科学与技术学院", + "中华人民共和国国家发展和改革委员会", + + // Mixed content - should return true + "Hello 世界", + "Visual Studio 代码编辑器", + "QQ音乐 Music Player", + "Windows 10 操作系统", + "GitHub 代码仓库管理平台", + + // Edge cases + "", + " ", + "123456", + "!@#$%^&*()", + "café résumé naïve", // Accented characters (not Chinese) + + // Long strings for performance testing + "This is a very long English string that contains no Chinese characters but is designed to test performance with longer text content that might appear in file names or application descriptions", + "这是一个非常长的中文字符串,包含了很多汉字,用来测试在处理较长中文文本时的性能表现,比如可能出现在文件名或应用程序描述中的文本内容", + "This is a mixed 混合内容的字符串 that contains both English and Chinese characters 中英文混合 to test performance with 复杂的文本内容 in real-world scenarios 真实场景中的应用" + }; + + [Test] + public void ContainsChinese_CorrectnessTest() + { + // Verify ContainsChinese works correctly for known cases + ClassicAssert.IsFalse(ContainsChinese("Hello World"), "Pure English should return false"); + ClassicAssert.IsTrue(ContainsChinese("你好世界"), "Pure Chinese should return true"); + ClassicAssert.IsTrue(ContainsChinese("Hello 世界"), "Mixed content should return true"); + ClassicAssert.IsFalse(ContainsChinese(""), "Empty string should return false"); + ClassicAssert.IsFalse(ContainsChinese("123456"), "Numbers should return false"); + ClassicAssert.IsFalse(ContainsChinese("café résumé"), "Accented characters should return false"); + } + + [Test] + public void WordsHelper_CorrectnessTest() + { + // Verify WordsHelper.HasChinese works correctly for known cases + ClassicAssert.IsFalse(WordsHelper.HasChinese("Hello World"), "Pure English should return false"); + ClassicAssert.IsTrue(WordsHelper.HasChinese("你好世界"), "Pure Chinese should return true"); + ClassicAssert.IsTrue(WordsHelper.HasChinese("Hello 世界"), "Mixed content should return true"); + ClassicAssert.IsFalse(WordsHelper.HasChinese(""), "Empty string should return false"); + ClassicAssert.IsFalse(WordsHelper.HasChinese("123456"), "Numbers should return false"); + ClassicAssert.IsFalse(WordsHelper.HasChinese("café résumé"), "Accented characters should return false"); + } + + [Test] + public void BothMethods_ShouldProduceSameResults() + { + // Critical test: verify both methods produce identical results for all test cases + foreach (var testString in _testStrings) + { + var wordsHelperResult = WordsHelper.HasChinese(testString); + var containsChineseResult = ContainsChinese(testString); + + ClassicAssert.AreEqual(wordsHelperResult, containsChineseResult, + $"Results differ for string: '{testString}'. WordsHelper: {wordsHelperResult}, ContainsChinese: {containsChineseResult}"); + } + + Console.WriteLine($"✓ Both methods produce identical results for all {_testStrings.Count} test cases"); + } + + [Test] + public void PerformanceComparison_BasicBenchmark() + { + const int iterations = 1000000; + + Console.WriteLine("=== CHINESE CHARACTER DETECTION PERFORMANCE TEST ==="); + Console.WriteLine($"Test iterations: {iterations:N0}"); + Console.WriteLine($"Test strings: {_testStrings.Count}"); + Console.WriteLine($"Total operations: {iterations * _testStrings.Count:N0}"); + Console.WriteLine(); + + // Warmup to ensure JIT compilation + Console.WriteLine("Warming up..."); + for (int i = 0; i < 1000; i++) + { + foreach (var testString in _testStrings) + { + _ = ContainsChinese(testString); + _ = WordsHelper.HasChinese(testString); + } + } + + // Benchmark ContainsChinese method + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var sw1 = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < iterations; i++) + { + foreach (var testString in _testStrings) + { + _ = ContainsChinese(testString); + } + } + sw1.Stop(); + + // Benchmark WordsHelper.HasChinese method + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var sw2 = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < iterations; i++) + { + foreach (var testString in _testStrings) + { + _ = WordsHelper.HasChinese(testString); + } + } + sw2.Stop(); + + // Calculate and display results + var containsChineseMs = sw1.Elapsed.TotalMilliseconds; + var wordsHelperMs = sw2.Elapsed.TotalMilliseconds; + var speedRatio = wordsHelperMs / containsChineseMs; + var timeDifference = wordsHelperMs - containsChineseMs; + + Console.WriteLine("RESULTS:"); + Console.WriteLine($"ContainsChinese(): {containsChineseMs:F3} ms"); + Console.WriteLine($"WordsHelper.HasChinese(): {wordsHelperMs:F3} ms"); + Console.WriteLine($"Time difference: {timeDifference:F3} ms"); + Console.WriteLine($"Speed improvement: {speedRatio:F2}x"); + Console.WriteLine($"Performance gain: {((speedRatio - 1) * 100):F1}%"); + Console.WriteLine(); + + if (speedRatio > 1.0) + { + Console.WriteLine($"✓ ContainsChinese() is {speedRatio:F2}x faster than WordsHelper.HasChinese()"); + } + else + { + Console.WriteLine($"⚠ WordsHelper.HasChinese() is {(1/speedRatio):F2}x faster than ContainsChinese()"); + } + + // Test always passes - this is a measurement test + ClassicAssert.IsTrue(true); + } + + [Test] + public void PerformanceComparison_ByStringType() + { + Console.WriteLine("=== PERFORMANCE BY STRING TYPE ==="); + + var categories = new Dictionary> + { + ["Pure English"] = _testStrings.Where(s => !ContainsChinese(s) && s.All(c => c <= 127)).ToList(), + ["Pure Chinese"] = _testStrings.Where(s => ContainsChinese(s) && s.All(c => IsChineseCharacter(c) || char.IsWhiteSpace(c))).ToList(), + ["Mixed Content"] = _testStrings.Where(s => ContainsChinese(s) && s.Any(c => c <= 127 && char.IsLetter(c))).ToList(), + ["Edge Cases"] = _testStrings.Where(s => string.IsNullOrWhiteSpace(s) || s.All(c => !char.IsLetter(c))).ToList() + }; + + foreach (var category in categories) + { + if (category.Value.Count == 0) continue; + + Console.WriteLine($"\n{category.Key} ({category.Value.Count} strings):"); + + var sample = category.Value.First(); + var displayText = sample.Length > 40 ? sample.Substring(0, 40) + "..." : sample; + Console.WriteLine($" Sample: '{displayText}'"); + + const int categoryIterations = 5000; + + // Test each method + var sw1 = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < categoryIterations; i++) + { + foreach (var str in category.Value) + { + _ = ContainsChinese(str); + } + } + sw1.Stop(); + + var sw2 = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < categoryIterations; i++) + { + foreach (var str in category.Value) + { + _ = WordsHelper.HasChinese(str); + } + } + sw2.Stop(); + + var ratio = (double)sw2.ElapsedTicks / sw1.ElapsedTicks; + Console.WriteLine($" Performance: ContainsChinese is {ratio:F2}x faster"); + } + + ClassicAssert.IsTrue(true); + } + + /// + /// Optimized Chinese character detection using comprehensive CJK Unicode ranges + /// This method uses ReadOnlySpan for better performance and covers all CJK character ranges + /// + private static bool ContainsChinese(ReadOnlySpan text) + { + foreach (var c in text) + { + if (IsChineseCharacter(c)) + return true; + } + return false; + } + + /// + /// Check if a character is a Chinese character using comprehensive Unicode ranges + /// Covers CJK Unified Ideographs and all extension blocks + /// + private static bool IsChineseCharacter(char c) + { + return (c >= 0x4E00 && c <= 0x9FFF) || // CJK Unified Ideographs (most common Chinese characters) + (c >= 0x3400 && c <= 0x4DBF) || // CJK Extension A + (c >= 0x20000 && c <= 0x2A6DF) || // CJK Extension B + (c >= 0x2A700 && c <= 0x2B73F) || // CJK Extension C + (c >= 0x2B740 && c <= 0x2B81F) || // CJK Extension D + (c >= 0x2B820 && c <= 0x2CEAF) || // CJK Extension E + (c >= 0x2CEB0 && c <= 0x2EBEF) || // CJK Extension F + (c >= 0xF900 && c <= 0xFAFF) || // CJK Compatibility Ideographs + (c >= 0x2F800 && c <= 0x2FA1F); // CJK Compatibility Supplement + } + } +} diff --git a/Flow.Launcher.Test/FilesFoldersTest.cs b/Flow.Launcher.Test/FilesFoldersTest.cs index 3dead9918..2621fc2da 100644 --- a/Flow.Launcher.Test/FilesFoldersTest.cs +++ b/Flow.Launcher.Test/FilesFoldersTest.cs @@ -1,5 +1,6 @@ using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; +using NUnit.Framework.Legacy; namespace Flow.Launcher.Test { @@ -35,7 +36,7 @@ namespace Flow.Launcher.Test [TestCase(@"c:\barr", @"c:\foo\..\bar\baz", false)] public void GivenTwoPaths_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult) { - Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path)); + ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path)); } // Equality @@ -47,7 +48,7 @@ namespace Flow.Launcher.Test [TestCase(@"c:\foo", @"c:\foo\", true)] public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedResult(string parentPath, string path, bool expectedResult) { - Assert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult)); + ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult)); } } } diff --git a/Flow.Launcher.Test/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index 8286e142e..11ccff05b 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -1,7 +1,7 @@ - net7.0-windows10.0.19041.0 + net9.0-windows10.0.19041.0 {FF742965-9A80-41A5-B042-D6C7D3A21708} Library Properties @@ -39,6 +39,7 @@ + @@ -48,13 +49,13 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Flow.Launcher.Test/FuzzyMatcherTest.cs b/Flow.Launcher.Test/FuzzyMatcherTest.cs index d7f143218..090719642 100644 --- a/Flow.Launcher.Test/FuzzyMatcherTest.cs +++ b/Flow.Launcher.Test/FuzzyMatcherTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; @@ -21,6 +22,8 @@ namespace Flow.Launcher.Test private const string MicrosoftSqlServerManagementStudio = "Microsoft SQL Server Management Studio"; private const string VisualStudioCode = "Visual Studio Code"; + private readonly IAlphabet alphabet = null; + public List GetSearchStrings() => new List { @@ -34,7 +37,7 @@ namespace Flow.Launcher.Test OneOneOneOne }; - public List GetPrecisionScores() + public static List GetPrecisionScores() { var listToReturn = new List(); @@ -59,7 +62,7 @@ namespace Flow.Launcher.Test }; var results = new List(); - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); foreach (var str in sources) { results.Add(new Result @@ -71,20 +74,20 @@ namespace Flow.Launcher.Test results = results.Where(x => x.Score > 0).OrderByDescending(x => x.Score).ToList(); - Assert.IsTrue(results.Count == 3); - Assert.IsTrue(results[0].Title == "Inste"); - Assert.IsTrue(results[1].Title == "Install Package"); - Assert.IsTrue(results[2].Title == "file open in browser-test"); + ClassicAssert.IsTrue(results.Count == 3); + ClassicAssert.IsTrue(results[0].Title == "Inste"); + ClassicAssert.IsTrue(results[1].Title == "Install Package"); + ClassicAssert.IsTrue(results[2].Title == "file open in browser-test"); } [TestCase("Chrome")] public void WhenNotAllCharactersFoundInSearchString_ThenShouldReturnZeroScore(string searchString) { var compareString = "Can have rum only in my glass"; - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var scoreResult = matcher.FuzzyMatch(searchString, compareString).RawScore; - Assert.True(scoreResult == 0); + ClassicAssert.True(scoreResult == 0); } [TestCase("chr")] @@ -97,7 +100,7 @@ namespace Flow.Launcher.Test string searchTerm) { var results = new List(); - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); foreach (var str in GetSearchStrings()) { results.Add(new Result @@ -125,7 +128,7 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine(""); - Assert.IsFalse(filteredResult.Any(x => x.Score < precisionScore)); + ClassicAssert.IsFalse(filteredResult.Any(x => x.Score < precisionScore)); } } @@ -147,11 +150,11 @@ namespace Flow.Launcher.Test string queryString, string compareString, int expectedScore) { // When, Given - var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; var rawScore = matcher.FuzzyMatch(queryString, compareString).RawScore; // Should - Assert.AreEqual(expectedScore, rawScore, + ClassicAssert.AreEqual(expectedScore, rawScore, $"Expected score for compare string '{compareString}': {expectedScore}, Actual: {rawScore}"); } @@ -181,7 +184,7 @@ namespace Flow.Launcher.Test bool expectedPrecisionResult) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = expectedPrecisionScore}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = expectedPrecisionScore}; // Given var matchResult = matcher.FuzzyMatch(queryString, compareString); @@ -190,12 +193,12 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); Debug.WriteLine( - $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); + $"RAW SCORE: {matchResult.RawScore}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); // Should - Assert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), + ClassicAssert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), $"Query: {queryString}{Environment.NewLine} " + $"Compare: {compareString}{Environment.NewLine}" + $"Raw Score: {matchResult.RawScore}{Environment.NewLine}" + @@ -232,7 +235,7 @@ namespace Flow.Launcher.Test bool expectedPrecisionResult) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = expectedPrecisionScore}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = expectedPrecisionScore}; // Given var matchResult = matcher.FuzzyMatch(queryString, compareString); @@ -241,12 +244,12 @@ namespace Flow.Launcher.Test Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); Debug.WriteLine( - $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); + $"RAW SCORE: {matchResult.RawScore}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); // Should - Assert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), + ClassicAssert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), $"Query:{queryString}{Environment.NewLine} " + $"Compare:{compareString}{Environment.NewLine}" + $"Raw Score: {matchResult.RawScore}{Environment.NewLine}" + @@ -260,7 +263,7 @@ namespace Flow.Launcher.Test string queryString, string compareString1, string compareString2) { // When - var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; + var matcher = new StringMatcher(alphabet) {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; // Given var compareString1Result = matcher.FuzzyMatch(queryString, compareString1); @@ -277,7 +280,7 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); // Should - Assert.True(compareString1Result.Score > compareString2Result.Score, + ClassicAssert.True(compareString1Result.Score > compareString2Result.Score, $"Query: \"{queryString}\"{Environment.NewLine} " + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -293,7 +296,7 @@ namespace Flow.Launcher.Test string queryString, string compareString1, string compareString2) { // When - var matcher = new StringMatcher { UserSettingSearchPrecision = SearchPrecisionScore.Regular }; + var matcher = new StringMatcher(alphabet) { UserSettingSearchPrecision = SearchPrecisionScore.Regular }; // Given var compareString1Result = matcher.FuzzyMatch(queryString, compareString1); @@ -310,7 +313,7 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); // Should - Assert.True(compareString1Result.Score > compareString2Result.Score, + ClassicAssert.True(compareString1Result.Score > compareString2Result.Score, $"Query: \"{queryString}\"{Environment.NewLine} " + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -323,7 +326,7 @@ namespace Flow.Launcher.Test string secondName, string secondDescription, string secondExecutableName) { // Act - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var firstNameMatch = matcher.FuzzyMatch(queryString, firstName).RawScore; var firstDescriptionMatch = matcher.FuzzyMatch(queryString, firstDescription).RawScore; var firstExecutableNameMatch = matcher.FuzzyMatch(queryString, firstExecutableName).RawScore; @@ -336,7 +339,7 @@ namespace Flow.Launcher.Test var secondScore = new[] {secondNameMatch, secondDescriptionMatch, secondExecutableNameMatch}.Max(); // Assert - Assert.IsTrue(firstScore > secondScore, + ClassicAssert.IsTrue(firstScore > secondScore, $"Query: \"{queryString}\"{Environment.NewLine} " + $"Name of first: \"{firstName}\", Final Score: {firstScore}{Environment.NewLine}" + $"Should be greater than{Environment.NewLine}" + @@ -358,9 +361,9 @@ namespace Flow.Launcher.Test public void WhenGivenAnAcronymQuery_ShouldReturnAcronymScore(string queryString, string compareString, int desiredScore) { - var matcher = new StringMatcher(); + var matcher = new StringMatcher(alphabet); var score = matcher.FuzzyMatch(queryString, compareString).Score; - Assert.IsTrue(score == desiredScore, + ClassicAssert.IsTrue(score == desiredScore, $@"Query: ""{queryString}"" CompareString: ""{compareString}"" Score: {score} diff --git a/Flow.Launcher.Test/HttpTest.cs b/Flow.Launcher.Test/HttpTest.cs index e72ad7a67..4f135978a 100644 --- a/Flow.Launcher.Test/HttpTest.cs +++ b/Flow.Launcher.Test/HttpTest.cs @@ -1,4 +1,5 @@ -using NUnit.Framework; +using NUnit.Framework; +using NUnit.Framework.Legacy; using System; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Infrastructure.Http; @@ -16,16 +17,16 @@ namespace Flow.Launcher.Test proxy.Enabled = true; proxy.Server = "127.0.0.1"; - Assert.AreEqual(Http.WebProxy.Address, new Uri($"http://{proxy.Server}:{proxy.Port}")); - Assert.IsNull(Http.WebProxy.Credentials); + ClassicAssert.AreEqual(Http.WebProxy.Address, new Uri($"http://{proxy.Server}:{proxy.Port}")); + ClassicAssert.IsNull(Http.WebProxy.Credentials); proxy.UserName = "test"; - Assert.NotNull(Http.WebProxy.Credentials); - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").UserName, proxy.UserName); - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, ""); + ClassicAssert.NotNull(Http.WebProxy.Credentials); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").UserName, proxy.UserName); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, ""); proxy.Password = "test password"; - Assert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, proxy.Password); + ClassicAssert.AreEqual(Http.WebProxy.Credentials.GetCredential(Http.WebProxy.Address, "Basic").Password, proxy.Password); } } } diff --git a/Flow.Launcher.Test/PluginLoadTest.cs b/Flow.Launcher.Test/PluginLoadTest.cs index d6ba48f19..2cc05f95a 100644 --- a/Flow.Launcher.Test/PluginLoadTest.cs +++ b/Flow.Launcher.Test/PluginLoadTest.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; using System.Collections.Generic; @@ -15,37 +16,37 @@ namespace Flow.Launcher.Test // Given var duplicateList = new List { - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.1" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.2" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B4085823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "ABC0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "ABC0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" @@ -56,11 +57,11 @@ namespace Flow.Launcher.Test (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); // Then - Assert.True(unique.FirstOrDefault().ID == "CEA0TYUC6D3B4085823D60DC76F28855" && unique.FirstOrDefault().Version == "1.0.2"); - Assert.True(unique.Count() == 1); + ClassicAssert.True(unique.FirstOrDefault().ID == "CEA0TYUC6D3B4085823D60DC76F28855" && unique.FirstOrDefault().Version == "1.0.2"); + ClassicAssert.True(unique.Count == 1); - Assert.False(duplicates.Any(x => x.Version == "1.0.2" && x.ID == "CEA0TYUC6D3B4085823D60DC76F28855")); - Assert.True(duplicates.Count() == 6); + ClassicAssert.False(duplicates.Any(x => x.Version == "1.0.2" && x.ID == "CEA0TYUC6D3B4085823D60DC76F28855")); + ClassicAssert.True(duplicates.Count == 6); } [Test] @@ -69,12 +70,12 @@ namespace Flow.Launcher.Test // Given var duplicateList = new List { - new PluginMetadata + new() { ID = "CEA0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" }, - new PluginMetadata + new() { ID = "CEA0TYUC6D3B7855823D60DC76F28855", Version = "1.0.0" @@ -85,8 +86,8 @@ namespace Flow.Launcher.Test (var unique, var duplicates) = PluginConfig.GetUniqueLatestPluginMetadata(duplicateList); // Then - Assert.True(unique.Count() == 0); - Assert.True(duplicates.Count() == 2); + ClassicAssert.True(unique.Count == 0); + ClassicAssert.True(duplicates.Count == 2); } } } diff --git a/Flow.Launcher.Test/Plugins/CalculatorTest.cs b/Flow.Launcher.Test/Plugins/CalculatorTest.cs new file mode 100644 index 000000000..b075813db --- /dev/null +++ b/Flow.Launcher.Test/Plugins/CalculatorTest.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Flow.Launcher.Plugin.Calculator; +using Mages.Core; +using NUnit.Framework; +using NUnit.Framework.Legacy; + +namespace Flow.Launcher.Test.Plugins +{ + [TestFixture] + public class CalculatorPluginTest + { + private readonly Main _plugin; + private readonly Settings _settings = new() + { + DecimalSeparator = DecimalSeparator.UseSystemLocale, + MaxDecimalPlaces = 10, + ShowErrorMessage = false // Make sure we return the empty results when error occurs + }; + private readonly Engine _engine = new(new Configuration + { + Scope = new Dictionary + { + { "e", Math.E }, // e is not contained in the default mages engine + } + }); + + public CalculatorPluginTest() + { + _plugin = new Main(); + + var settingField = typeof(Main).GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance); + if (settingField == null) + Assert.Fail("Could not find field '_settings' on Flow.Launcher.Plugin.Calculator.Main"); + settingField.SetValue(_plugin, _settings); + + var engineField = typeof(Main).GetField("MagesEngine", BindingFlags.NonPublic | BindingFlags.Static); + if (engineField == null) + Assert.Fail("Could not find static field 'MagesEngine' on Flow.Launcher.Plugin.Calculator.Main"); + engineField.SetValue(null, _engine); + } + + // Basic operations + [TestCase(@"1+1", "2")] + [TestCase(@"2-1", "1")] + [TestCase(@"2*2", "4")] + [TestCase(@"4/2", "2")] + [TestCase(@"2^3", "8")] + // Decimal places + [TestCase(@"10/3", "3.3333333333")] + // Parentheses + [TestCase(@"(1+2)*3", "9")] + [TestCase(@"2^(1+2)", "8")] + // Functions + [TestCase(@"pow(2,3)", "8")] + [TestCase(@"min(1,-1,-2)", "-2")] + [TestCase(@"max(1,-1,-2)", "1")] + [TestCase(@"sqrt(16)", "4")] + [TestCase(@"sin(pi)", "0.0000000000")] + [TestCase(@"cos(0)", "1")] + [TestCase(@"tan(0)", "0")] + [TestCase(@"log10(100)", "2")] + [TestCase(@"log(100)", "2")] + [TestCase(@"log2(8)", "3")] + [TestCase(@"ln(e)", "1")] + [TestCase(@"abs(-5)", "5")] + // Constants + [TestCase(@"pi", "3.1415926536")] + // Complex expressions + [TestCase(@"(2+3)*sqrt(16)-log(100)/ln(e)", "18")] + [TestCase(@"sin(pi/2)+cos(0)+tan(0)", "2")] + // Error handling (should return empty result) + [TestCase(@"10/0", "")] + [TestCase(@"sqrt(-1)", "")] + [TestCase(@"log(0)", "")] + [TestCase(@"invalid_expression", "")] + public void CalculatorTest(string expression, string result) + { + ClassicAssert.AreEqual(GetCalculationResult(expression), result); + } + + private string GetCalculationResult(string expression) + { + var results = _plugin.Query(new Plugin.Query() + { + Search = expression + }); + return results.Count > 0 ? results[0].Title : string.Empty; + } + } +} diff --git a/Flow.Launcher.Test/Plugins/ExplorerTest.cs b/Flow.Launcher.Test/Plugins/ExplorerTest.cs index 80cb74729..9ec952155 100644 --- a/Flow.Launcher.Test/Plugins/ExplorerTest.cs +++ b/Flow.Launcher.Test/Plugins/ExplorerTest.cs @@ -5,12 +5,10 @@ using Flow.Launcher.Plugin.Explorer.Search.DirectoryInfo; using Flow.Launcher.Plugin.Explorer.Search.WindowsIndex; using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; +using NUnit.Framework.Legacy; using System; -using System.Collections.Generic; using System.IO; using System.Runtime.Versioning; -using System.Threading; -using System.Threading.Tasks; using static Flow.Launcher.Plugin.Explorer.Search.SearchManager; namespace Flow.Launcher.Test.Plugins @@ -22,28 +20,6 @@ namespace Flow.Launcher.Test.Plugins [TestFixture] public class ExplorerTest { -#pragma warning disable CS1998 // async method with no await (more readable to leave it async to match the tested signature) - private async Task> MethodWindowsIndexSearchReturnsZeroResultsAsync(Query dummyQuery, string dummyString, CancellationToken dummyToken) - { - return new List(); - } -#pragma warning restore CS1998 - - private List MethodDirectoryInfoClassSearchReturnsTwoResults(Query dummyQuery, string dummyString, CancellationToken token) - { - return new List - { - new Result - { - Title = "Result 1" - }, - new Result - { - Title = "Result 2" - } - }; - } - private bool PreviousLocationExistsReturnsTrue(string dummyString) => true; private bool PreviousLocationNotExistReturnsFalse(string dummyString) => false; @@ -57,14 +33,14 @@ namespace Flow.Launcher.Test.Plugins var result = QueryConstructor.TopLevelDirectoryConstraint(folderPath); // Then - Assert.IsTrue(result == expectedString, + ClassicAssert.IsTrue(result == expectedString, $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + $"Actual: {result}{Environment.NewLine}"); } [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 @@ -74,7 +50,7 @@ namespace Flow.Launcher.Test.Plugins var queryString = queryConstructor.Directory(folderPath); // Then - Assert.IsTrue(queryString.Replace(" ", " ") == expectedString.Replace(" ", " "), + ClassicAssert.IsTrue(queryString.Replace(" ", " ") == expectedString.Replace(" ", " "), $"Expected string: {expectedString}{Environment.NewLine} " + $"Actual string was: {queryString}{Environment.NewLine}"); } @@ -83,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) { @@ -94,7 +70,7 @@ namespace Flow.Launcher.Test.Plugins var queryString = queryConstructor.Directory(folderPath, userSearchString); // Then - Assert.AreEqual(expectedString, queryString); + ClassicAssert.AreEqual(expectedString, queryString); } [SupportedOSPlatform("windows7.0")] @@ -105,14 +81,14 @@ namespace Flow.Launcher.Test.Plugins const string resultString = QueryConstructor.RestrictionsForAllFilesAndFoldersSearch; // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [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) { @@ -128,30 +104,29 @@ namespace Flow.Launcher.Test.Plugins var resultString = queryConstructor.FilesAndFolders(userSearchString); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } - [SupportedOSPlatform("windows7.0")] [TestCase(@"some words", @"FREETEXT('some words')")] public void GivenWindowsIndexSearch_WhenQueryWhereRestrictionsIsForFileContentSearch_ThenShouldReturnFreeTextString( string querySearchString, string expectedString) { // Given - var queryConstructor = new QueryConstructor(new Settings()); + _ = new QueryConstructor(new Settings()); //When var resultString = QueryConstructor.RestrictionsForFileContentSearch(querySearchString); // Then - Assert.IsTrue(resultString == expectedString, + ClassicAssert.IsTrue(resultString == expectedString, $"Expected QueryWhereRestrictions string: {expectedString}{Environment.NewLine} " + $"Actual string was: {resultString}{Environment.NewLine}"); } [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) { @@ -162,12 +137,12 @@ namespace Flow.Launcher.Test.Plugins var resultString = queryConstructor.FileContent(userSearchString); // Then - Assert.IsTrue(resultString == expectedString, + ClassicAssert.IsTrue(resultString == expectedString, $"Expected query string: {expectedString}{Environment.NewLine} " + $"Actual string was: {resultString}{Environment.NewLine}"); } - public void GivenQuery_WhenActionKeywordForFileContentSearchExists_ThenFileContentSearchRequiredShouldReturnTrue() + public static void GivenQuery_WhenActionKeywordForFileContentSearchExists_ThenFileContentSearchRequiredShouldReturnTrue() { // Given var query = new Query @@ -181,7 +156,7 @@ namespace Flow.Launcher.Test.Plugins var result = searchManager.IsFileContentSearch(query.ActionKeyword); // Then - Assert.IsTrue(result, + ClassicAssert.IsTrue(result, $"Expected True for file content search. {Environment.NewLine} " + $"Actual result was: {result}{Environment.NewLine}"); } @@ -206,7 +181,7 @@ namespace Flow.Launcher.Test.Plugins var result = FilesFolders.IsLocationPathString(querySearchString); //Then - Assert.IsTrue(result == expectedResult, + ClassicAssert.IsTrue(result == expectedResult, $"Expected query search string check result is: {expectedResult} {Environment.NewLine} " + $"Actual check result is {result} {Environment.NewLine}"); @@ -233,7 +208,7 @@ namespace Flow.Launcher.Test.Plugins var previousDirectoryPath = FilesFolders.GetPreviousExistingDirectory(previousLocationExists, path); //Then - Assert.IsTrue(previousDirectoryPath == expectedString, + ClassicAssert.IsTrue(previousDirectoryPath == expectedString, $"Expected path string: {expectedString} {Environment.NewLine} " + $"Actual path string is {previousDirectoryPath} {Environment.NewLine}"); } @@ -246,7 +221,7 @@ namespace Flow.Launcher.Test.Plugins var returnedPath = FilesFolders.ReturnPreviousDirectoryIfIncompleteString(path); //Then - Assert.IsTrue(returnedPath == expectedString, + ClassicAssert.IsTrue(returnedPath == expectedString, $"Expected path string: {expectedString} {Environment.NewLine} " + $"Actual path string is {returnedPath} {Environment.NewLine}"); } @@ -260,7 +235,7 @@ namespace Flow.Launcher.Test.Plugins var resultString = QueryConstructor.RecursiveDirectoryConstraint(path); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [SupportedOSPlatform("windows7.0")] @@ -274,7 +249,7 @@ namespace Flow.Launcher.Test.Plugins var resultString = DirectoryInfoSearch.ConstructSearchCriteria(path); // Then - Assert.AreEqual(expectedString, resultString); + ClassicAssert.AreEqual(expectedString, resultString); } [TestCase("c:\\somefolder\\someotherfolder", ResultType.Folder, "irrelevant", false, true, "c:\\somefolder\\someotherfolder\\")] @@ -305,7 +280,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("c:\\somefolder\\somefile", ResultType.File, "irrelevant", false, true, "e c:\\somefolder\\somefile")] @@ -334,7 +309,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetPathWithActionKeyword(path, type, actionKeyword); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("somefolder", "c:\\somefolder\\", ResultType.Folder, "q", false, false, "q somefolder")] @@ -366,7 +341,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase("somefile", "c:\\somefolder\\somefile", ResultType.File, "q", false, false, "q somefile")] @@ -398,7 +373,7 @@ namespace Flow.Launcher.Test.Plugins var result = ResultManager.GetAutoCompleteText(title, query, path, resultType); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } [TestCase(@"c:\foo", @"c:\foo", true)] @@ -420,7 +395,7 @@ namespace Flow.Launcher.Test.Plugins }; // When, Then - Assert.AreEqual(expectedResult, comparator.Equals(result1, result2)); + ClassicAssert.AreEqual(expectedResult, comparator.Equals(result1, result2)); } [TestCase(@"c:\foo\", @"c:\foo\")] @@ -444,7 +419,7 @@ namespace Flow.Launcher.Test.Plugins var hash2 = comparator.GetHashCode(result2); // When, Then - Assert.IsTrue(hash1 == hash2); + ClassicAssert.IsTrue(hash1 == hash2); } [TestCase(@"%appdata%", true)] @@ -461,7 +436,7 @@ namespace Flow.Launcher.Test.Plugins var result = EnvironmentVariables.HasEnvironmentVar(path); // Then - Assert.AreEqual(result, expectedResult); + ClassicAssert.AreEqual(result, expectedResult); } } } diff --git a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs index 42a4630fe..497f874e7 100644 --- a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs @@ -1,12 +1,11 @@ -using NUnit.Framework; +using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; using System.Threading.Tasks; using System.IO; using System.Threading; using System.Text; -using System.Text.Json; -using System.Linq; using System.Collections.Generic; namespace Flow.Launcher.Test.Plugins @@ -40,13 +39,13 @@ namespace Flow.Launcher.Test.Plugins Search = resultText }, default); - Assert.IsNotNull(results); + ClassicAssert.IsNotNull(results); foreach (var result in results) { - Assert.IsNotNull(result); - Assert.IsNotNull(result.AsyncAction); - Assert.IsNotNull(result.Title); + ClassicAssert.IsNotNull(result); + ClassicAssert.IsNotNull(result.AsyncAction); + ClassicAssert.IsNotNull(result.Title); } } @@ -56,12 +55,11 @@ namespace Flow.Launcher.Test.Plugins new JsonRPCQueryResponseModel(0, new List()), new JsonRPCQueryResponseModel(0, new List { - new JsonRPCResult + new() { Title = "Test1", SubTitle = "Test2" } }) }; - } } diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 7ccac5bd5..0dd1fe489 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -1,7 +1,8 @@ using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Plugin.Url; -namespace Flow.Launcher.Test +namespace Flow.Launcher.Test.Plugins { [TestFixture] public class UrlPluginTest @@ -10,23 +11,23 @@ namespace Flow.Launcher.Test public void URLMatchTest() { var plugin = new Main(); - Assert.IsTrue(plugin.IsURL("http://www.google.com")); - Assert.IsTrue(plugin.IsURL("https://www.google.com")); - Assert.IsTrue(plugin.IsURL("http://google.com")); - Assert.IsTrue(plugin.IsURL("www.google.com")); - Assert.IsTrue(plugin.IsURL("google.com")); - Assert.IsTrue(plugin.IsURL("http://localhost")); - Assert.IsTrue(plugin.IsURL("https://localhost")); - Assert.IsTrue(plugin.IsURL("http://localhost:80")); - Assert.IsTrue(plugin.IsURL("https://localhost:80")); - Assert.IsTrue(plugin.IsURL("http://110.10.10.10")); - Assert.IsTrue(plugin.IsURL("110.10.10.10")); - Assert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("http://google.com")); + ClassicAssert.IsTrue(plugin.IsURL("www.google.com")); + ClassicAssert.IsTrue(plugin.IsURL("google.com")); + ClassicAssert.IsTrue(plugin.IsURL("http://localhost")); + ClassicAssert.IsTrue(plugin.IsURL("https://localhost")); + ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80")); + ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80")); + ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); - Assert.IsFalse(plugin.IsURL("wwww")); - Assert.IsFalse(plugin.IsURL("wwww.c")); - Assert.IsFalse(plugin.IsURL("wwww.c")); + ClassicAssert.IsFalse(plugin.IsURL("wwww")); + ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); + ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); } } } diff --git a/Flow.Launcher.Test/QueryBuilderTest.cs b/Flow.Launcher.Test/QueryBuilderTest.cs index aa0c8da12..c8ac17748 100644 --- a/Flow.Launcher.Test/QueryBuilderTest.cs +++ b/Flow.Launcher.Test/QueryBuilderTest.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using NUnit.Framework; +using NUnit.Framework.Legacy; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Plugin; @@ -17,17 +18,17 @@ namespace Flow.Launcher.Test Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("> ping google.com -n 20 -6", q.RawQuery); - Assert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); - Assert.AreEqual(">", q.ActionKeyword); + ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.RawQuery); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.Search, "Search should not start with the ActionKeyword."); + ClassicAssert.AreEqual(">", q.ActionKeyword); - Assert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); + ClassicAssert.AreEqual(5, q.SearchTerms.Length, "The length of SearchTerms should match."); - Assert.AreEqual("ping", q.FirstSearch); - Assert.AreEqual("google.com", q.SecondSearch); - Assert.AreEqual("-n", q.ThirdSearch); + ClassicAssert.AreEqual("ping", q.FirstSearch); + ClassicAssert.AreEqual("google.com", q.SecondSearch); + ClassicAssert.AreEqual("-n", q.ThirdSearch); - Assert.AreEqual("google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); + ClassicAssert.AreEqual("google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } [Test] @@ -40,11 +41,11 @@ namespace Flow.Launcher.Test Query q = QueryBuilder.Build("> ping google.com -n 20 -6", nonGlobalPlugins); - Assert.AreEqual("> ping google.com -n 20 -6", q.Search); - Assert.AreEqual(q.Search, q.RawQuery, "RawQuery should be equal to Search."); - Assert.AreEqual(6, q.SearchTerms.Length, "The length of SearchTerms should match."); - Assert.AreNotEqual(">", q.ActionKeyword, "ActionKeyword should not match that of a disabled plugin."); - Assert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); + ClassicAssert.AreEqual("> ping google.com -n 20 -6", q.Search); + ClassicAssert.AreEqual(q.Search, q.RawQuery, "RawQuery should be equal to Search."); + ClassicAssert.AreEqual(6, q.SearchTerms.Length, "The length of SearchTerms should match."); + ClassicAssert.AreNotEqual(">", q.ActionKeyword, "ActionKeyword should not match that of a disabled plugin."); + ClassicAssert.AreEqual("ping google.com -n 20 -6", q.SecondToEndSearch, "SecondToEndSearch should be trimmed of multiple whitespace characters"); } [Test] @@ -52,13 +53,13 @@ namespace Flow.Launcher.Test { Query q = QueryBuilder.Build("file.txt file2 file3", new Dictionary()); - Assert.AreEqual("file.txt file2 file3", q.Search); - Assert.AreEqual("", q.ActionKeyword); + ClassicAssert.AreEqual("file.txt file2 file3", q.Search); + ClassicAssert.AreEqual("", q.ActionKeyword); - Assert.AreEqual("file.txt", q.FirstSearch); - Assert.AreEqual("file2", q.SecondSearch); - Assert.AreEqual("file3", q.ThirdSearch); - Assert.AreEqual("file2 file3", q.SecondToEndSearch); + ClassicAssert.AreEqual("file.txt", q.FirstSearch); + ClassicAssert.AreEqual("file2", q.SecondSearch); + ClassicAssert.AreEqual("file3", q.ThirdSearch); + ClassicAssert.AreEqual("file2 file3", q.SecondToEndSearch); } } } diff --git a/Flow.Launcher.Test/TranslationMappingTest.cs b/Flow.Launcher.Test/TranslationMappingTest.cs new file mode 100644 index 000000000..bd3636f0a --- /dev/null +++ b/Flow.Launcher.Test/TranslationMappingTest.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Reflection; +using Flow.Launcher.Infrastructure; +using NUnit.Framework; +using NUnit.Framework.Legacy; + +namespace Flow.Launcher.Test +{ + [TestFixture] + public class TranslationMappingTest + { + [Test] + public void AddNewIndex_ShouldAddTranslatedIndexPlusLength() + { + var mapping = new TranslationMapping(); + mapping.AddNewIndex(5, 3); + mapping.AddNewIndex(8, 2); + + // 5+3=8, 8+2=10 + ClassicAssert.AreEqual(2, GetOriginalToTranslatedCount(mapping)); + ClassicAssert.AreEqual(8, GetOriginalToTranslatedAt(mapping, 0)); + ClassicAssert.AreEqual(10, GetOriginalToTranslatedAt(mapping, 1)); + } + + [TestCase(0, 0)] + [TestCase(2, 1)] + [TestCase(3, 1)] + [TestCase(5, 2)] + [TestCase(6, 2)] + public void MapToOriginalIndex_ShouldReturnExpectedIndex(int translatedIndex, int expectedOriginalIndex) + { + var mapping = new TranslationMapping(); + // a测试 + // a Ce Shi + mapping.AddNewIndex(0, 1); + mapping.AddNewIndex(2, 2); + mapping.AddNewIndex(5, 3); + + var result = mapping.MapToOriginalIndex(translatedIndex); + ClassicAssert.AreEqual(expectedOriginalIndex, result); + } + + private static int GetOriginalToTranslatedCount(TranslationMapping mapping) + { + var field = typeof(TranslationMapping).GetField("_originalToTranslated", BindingFlags.NonPublic | BindingFlags.Instance); + var list = (List)field.GetValue(mapping); + return list.Count; + } + + private static int GetOriginalToTranslatedAt(TranslationMapping mapping, int index) + { + var field = typeof(TranslationMapping).GetField("_originalToTranslated", BindingFlags.NonPublic | BindingFlags.Instance); + var list = (List)field.GetValue(mapping); + return list[index]; + } + } +} diff --git a/Flow.Launcher/ActionKeywords.xaml b/Flow.Launcher/ActionKeywords.xaml index 740b0d402..5af76f37f 100644 --- a/Flow.Launcher/ActionKeywords.xaml +++ b/Flow.Launcher/ActionKeywords.xaml @@ -14,118 +14,118 @@ + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + BorderThickness="0 1 0 0"> diff --git a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs index ce377000f..6c88241b4 100644 --- a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs +++ b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs @@ -1,89 +1,64 @@ -using Flow.Launcher.Core.Resource; -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure.UserSettings; -using System.Collections.ObjectModel; -using System.Linq; -using System.Windows; -using System.Windows.Input; +using System.Windows; using System.Windows.Controls; -using Flow.Launcher.Core; +using System.Windows.Input; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher { public partial class CustomQueryHotkeySetting : Window { - private SettingWindow _settingWidow; - private bool update; - private CustomPluginHotkey updateCustomHotkey; - public Settings Settings { get; } + public string Hotkey { get; set; } = string.Empty; + public string ActionKeyword { get; set; } = string.Empty; - public CustomQueryHotkeySetting(SettingWindow settingWidow, Settings settings) + private readonly bool update; + private readonly CustomPluginHotkey originalCustomHotkey; + + public CustomQueryHotkeySetting() { - _settingWidow = settingWidow; - Settings = settings; InitializeComponent(); + tbAdd.Visibility = Visibility.Visible; + } + + public CustomQueryHotkeySetting(CustomPluginHotkey hotkey) + { + originalCustomHotkey = hotkey; + update = true; + ActionKeyword = originalCustomHotkey.ActionKeyword; + InitializeComponent(); + tbUpdate.Visibility = Visibility.Visible; + HotkeyControl.SetHotkey(originalCustomHotkey.Hotkey, false); } private void BtnCancel_OnClick(object sender, RoutedEventArgs e) { + DialogResult = false; Close(); } private void btnAdd_OnClick(object sender, RoutedEventArgs e) { - if (!update) + Hotkey = HotkeyControl.CurrentHotkey.HotkeyRaw; + + if (string.IsNullOrEmpty(Hotkey) && string.IsNullOrEmpty(ActionKeyword)) { - Settings.CustomPluginHotkeys ??= new ObservableCollection(); - - var pluginHotkey = new CustomPluginHotkey - { - Hotkey = HotkeyControl.CurrentHotkey.HotkeyRaw, ActionKeyword = tbAction.Text - }; - Settings.CustomPluginHotkeys.Add(pluginHotkey); - - HotKeyMapper.SetCustomQueryHotkey(pluginHotkey); - } - else - { - var oldHotkey = updateCustomHotkey.Hotkey; - updateCustomHotkey.ActionKeyword = tbAction.Text; - updateCustomHotkey.Hotkey = HotkeyControl.CurrentHotkey.HotkeyRaw; - //remove origin hotkey - HotKeyMapper.UnregisterHotkey(oldHotkey); - HotKeyMapper.SetCustomQueryHotkey(updateCustomHotkey); - } - - Close(); - } - - - public void UpdateItem(CustomPluginHotkey item) - { - updateCustomHotkey = Settings.CustomPluginHotkeys.FirstOrDefault(o => - o.ActionKeyword == item.ActionKeyword && o.Hotkey == item.Hotkey); - if (updateCustomHotkey == null) - { - MessageBoxEx.Show(InternationalizationManager.Instance.GetTranslation("invalidPluginHotkey")); - Close(); + App.API.ShowMsgBox(App.API.GetTranslation("emptyPluginHotkey")); return; } - tbAction.Text = updateCustomHotkey.ActionKeyword; - HotkeyControl.SetHotkey(updateCustomHotkey.Hotkey, false); - update = true; - lblAdd.Text = InternationalizationManager.Instance.GetTranslation("update"); + DialogResult = !update || originalCustomHotkey.Hotkey != Hotkey || originalCustomHotkey.ActionKeyword != ActionKeyword; + Close(); } private void BtnTestActionKeyword_OnClick(object sender, RoutedEventArgs e) { App.API.ChangeQuery(tbAction.Text); - Application.Current.MainWindow.Show(); - Application.Current.MainWindow.Opacity = 1; + App.API.ShowMainWindow(); Application.Current.MainWindow.Focus(); } private void cmdEsc_OnPress(object sender, ExecutedRoutedEventArgs e) { + DialogResult = false; Close(); } diff --git a/Flow.Launcher/CustomShortcutSetting.xaml b/Flow.Launcher/CustomShortcutSetting.xaml index 8df569a0e..5185354e7 100644 --- a/Flow.Launcher/CustomShortcutSetting.xaml +++ b/Flow.Launcher/CustomShortcutSetting.xaml @@ -30,14 +30,11 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/ReportWindow.xaml.cs b/Flow.Launcher/ReportWindow.xaml.cs index a535dfb3e..ae0767934 100644 --- a/Flow.Launcher/ReportWindow.xaml.cs +++ b/Flow.Launcher/ReportWindow.xaml.cs @@ -1,14 +1,14 @@ -using Flow.Launcher.Core.ExternalPlugins; -using System; +using System; using System.Globalization; using System.IO; -using System.Text; using System.Linq; +using System.Text; using System.Windows; using System.Windows.Documents; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin.SharedCommands; namespace Flow.Launcher @@ -38,25 +38,26 @@ namespace Flow.Launcher private void SetException(Exception exception) { - string path = Log.CurrentLogDirectory; + var path = DataLocation.VersionLogDirectory; var directory = new DirectoryInfo(path); var log = directory.GetFiles().OrderByDescending(f => f.LastWriteTime).First(); var websiteUrl = exception switch - { - FlowPluginException pluginException =>GetIssuesUrl(pluginException.Metadata.Website), - _ => Constant.IssuesUrl - }; - + { + FlowPluginException pluginException => GetIssuesUrl(pluginException.Metadata.Website), + _ => Constant.IssuesUrl + }; - var paragraph = Hyperlink("Please open new issue in: ", websiteUrl); - paragraph.Inlines.Add($"1. upload log file: {log.FullName}\n"); - paragraph.Inlines.Add($"2. copy below exception message"); + var paragraph = Hyperlink(App.API.GetTranslation("reportWindow_please_open_issue"), websiteUrl); + paragraph.Inlines.Add(string.Format(App.API.GetTranslation("reportWindow_upload_log"), log.FullName)); + paragraph.Inlines.Add("\n"); + paragraph.Inlines.Add(App.API.GetTranslation("reportWindow_copy_below")); ErrorTextbox.Document.Blocks.Add(paragraph); StringBuilder content = new StringBuilder(); content.AppendLine(ErrorReporting.RuntimeInfo()); content.AppendLine(ErrorReporting.DependenciesInfo()); + content.AppendLine(); content.AppendLine($"Date: {DateTime.Now.ToString(CultureInfo.InvariantCulture)}"); content.AppendLine("Exception:"); content.AppendLine(exception.ToString()); @@ -65,24 +66,51 @@ namespace Flow.Launcher ErrorTextbox.Document.Blocks.Add(paragraph); } - private Paragraph Hyperlink(string textBeforeUrl, string url) + private static Paragraph Hyperlink(string textBeforeUrl, string url) { - var paragraph = new Paragraph(); - paragraph.Margin = new Thickness(0); - - var link = new Hyperlink + var paragraph = new Paragraph { - IsEnabled = true + Margin = new Thickness(0) }; - link.Inlines.Add(url); - link.NavigateUri = new Uri(url); - link.Click += (s, e) => SearchWeb.OpenInBrowserTab(url); + + Hyperlink link = null; + try + { + var uri = new Uri(url); + + link = new Hyperlink + { + IsEnabled = true + }; + link.Inlines.Add(url); + link.NavigateUri = uri; + link.Click += (s, e) => SearchWeb.OpenInBrowserTab(url); + } + catch (Exception) + { + // Leave link as null if the URL is invalid + } paragraph.Inlines.Add(textBeforeUrl); - paragraph.Inlines.Add(link); + paragraph.Inlines.Add(" "); + if (link is null) + { + // Add the URL as plain text if it is invalid + paragraph.Inlines.Add(url); + } + else + { + // Add the hyperlink if it is valid + paragraph.Inlines.Add(link); + } paragraph.Inlines.Add("\n"); return paragraph; } + + private void BtnCancel_OnClick(object sender, RoutedEventArgs e) + { + Close(); + } } } diff --git a/Flow.Launcher/Resources/Controls/Card.xaml b/Flow.Launcher/Resources/Controls/Card.xaml index c29a5f602..e3c5f8194 100644 --- a/Flow.Launcher/Resources/Controls/Card.xaml +++ b/Flow.Launcher/Resources/Controls/Card.xaml @@ -20,39 +20,39 @@ - - + + - + - + - + - - + + - + - + - + @@ -73,7 +73,7 @@ @@ -91,7 +91,7 @@ @@ -107,8 +107,8 @@ - - + + @@ -120,11 +120,11 @@ diff --git a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs index 9b19ffd86..bc167184b 100644 --- a/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs +++ b/Flow.Launcher/Resources/Controls/HotkeyDisplay.xaml.cs @@ -42,14 +42,11 @@ namespace Flow.Launcher.Resources.Controls private static void keyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var control = d as UserControl; - if (null == control) return; // This should not be possible + if (d is not UserControl) return; // This should not be possible - var newValue = e.NewValue as string; - if (null == newValue) return; + if (e.NewValue is not string newValue) return; - if (d is not HotkeyDisplay hotkeyDisplay) - return; + if (d is not HotkeyDisplay hotkeyDisplay) return; hotkeyDisplay.Values.Clear(); foreach (var key in newValue.Split('+')) diff --git a/Flow.Launcher/Resources/Controls/InfoBar.xaml b/Flow.Launcher/Resources/Controls/InfoBar.xaml new file mode 100644 index 000000000..2ddcbdd0c --- /dev/null +++ b/Flow.Launcher/Resources/Controls/InfoBar.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 +777,33 @@ - + + + + + + + + @@ -773,7 +811,7 @@ - + @@ -792,7 +830,8 @@ - + + @@ -802,7 +841,8 @@ - + + @@ -822,7 +862,7 @@ - + @@ -1103,7 +1143,8 @@ IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" PlacementTarget="{Binding ElementName=Background}" - PopupAnimation="None"> + PopupAnimation="None" + VerticalOffset="-1"> @@ -1123,11 +1164,11 @@ + Background="{DynamicResource CustomPopUpBorderBG}" + CornerRadius="5"> @@ -1157,8 +1198,8 @@ - - + + @@ -1285,7 +1326,6 @@ BasedOn="{StaticResource DefaultComboBoxStyle}" TargetType="ComboBox"> - @@ -1503,7 +1543,7 @@ - + @@ -1550,6 +1590,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..ea651d4ee 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage1.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage1.xaml @@ -103,15 +103,15 @@ - + - - + + @@ -127,7 +127,7 @@ Style="{DynamicResource StyleImageFadeIn}" /> - + - - + + + Text="{DynamicResource Welcome_Page1_Title}" TextWrapping="WrapWithOverflow"/> (); + 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 dfb4a7b94..6bd84e08c 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml @@ -38,7 +38,7 @@ - + @@ -56,11 +56,11 @@ Margin="0" Background="{Binding PreviewBackground}"> - + @@ -89,39 +89,38 @@ - - + + + Text="{DynamicResource Welcome_Page2_Title}" TextWrapping="WrapWithOverflow"/> - + diff --git a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs index bb5124c16..e9b5b383c 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs +++ b/Flow.Launcher/Resources/Pages/WelcomePage2.xaml.cs @@ -1,27 +1,30 @@ -using Flow.Launcher.Helper; -using Flow.Launcher.Infrastructure.Hotkey; -using Flow.Launcher.Infrastructure.UserSettings; -using System; -using System.Windows; -using System.Windows.Media; +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; 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] @@ -29,5 +32,10 @@ namespace Flow.Launcher.Resources.Pages { HotKeyMapper.RegisterHotkey(hotkey.HotkeyRaw, hotkey.PreviousHotkey, HotKeyMapper.ToggleHotkey); } + + public Brush PreviewBackground + { + get => WallpaperPathRetrieval.GetWallpaperBrush(); + } } } 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}" /> - + + Text="{DynamicResource Welcome_Page4_Title}" TextWrapping="WrapWithOverflow"/> 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..997f724b9 100644 --- a/Flow.Launcher/Resources/Pages/WelcomePage5.xaml +++ b/Flow.Launcher/Resources/Pages/WelcomePage5.xaml @@ -53,15 +53,15 @@ - + - + - - + + @@ -79,18 +79,18 @@ - + + Text="{DynamicResource Welcome_Page5_Title}" TextWrapping="WrapWithOverflow"/> - + - + (); + 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.ShowMsgError(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/Resources/Segoe Fluent Icons.ttf b/Flow.Launcher/Resources/SegoeFluentIcons.ttf similarity index 100% rename from Flow.Launcher/Resources/Segoe Fluent Icons.ttf rename to Flow.Launcher/Resources/SegoeFluentIcons.ttf diff --git a/Flow.Launcher/Resources/SettingWindowStyle.xaml b/Flow.Launcher/Resources/SettingWindowStyle.xaml index fc9246aa3..3ebd22c74 100644 --- a/Flow.Launcher/Resources/SettingWindowStyle.xaml +++ b/Flow.Launcher/Resources/SettingWindowStyle.xaml @@ -6,7 +6,6 @@ - F1 M512,512z M0,0z M448,256C448,150,362,64,256,64L256,448C362,448,448,362,448,256z M0,256A256,256,0,1,1,512,256A256,256,0,1,1,0,256z diff --git a/Flow.Launcher/Resources/double_pinyin.json b/Flow.Launcher/Resources/double_pinyin.json new file mode 100644 index 000000000..83972038f --- /dev/null +++ b/Flow.Launcher/Resources/double_pinyin.json @@ -0,0 +1 @@ +{"XiaoHe":{"Lv":"lv","Lve":"lt","Lue":"lt","Nv":"nv","Nve":"nt","Nue":"nt","A":"aa","O":"oo","E":"ee","Ai":"ai","Ei":"ei","Ao":"ao","Ou":"ou","An":"an","En":"en","Ang":"ah","Eng":"eg","Er":"er","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yc","You":"yz","Yan":"yj","Yin":"yb","Yang":"yh","Ying":"yk","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wd","Wei":"ww","Wan":"wj","Wen":"wf","Wang":"wh","Weng":"wg","Yu":"yu","Yue":"yt","Yuan":"yr","Yun":"yy","Yong":"ys","Ba":"ba","Bai":"bd","Ban":"bj","Bang":"bh","Bao":"bc","Bei":"bw","Ben":"bf","Beng":"bg","Bi":"bi","Bian":"bm","Biang":"bl","Biao":"bn","Bie":"bp","Bin":"bb","Bing":"bk","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cd","Can":"cj","Cang":"ch","Cao":"cc","Ce":"ce","Cen":"cf","Ceng":"cg","Cha":"ia","Chai":"id","Chan":"ij","Chang":"ih","Chao":"ic","Che":"ie","Chen":"if","Cheng":"ig","Chi":"ii","Chong":"is","Chou":"iz","Chu":"iu","Chua":"ix","Chuai":"ik","Chuan":"ir","Chuang":"il","Chui":"iv","Chun":"iy","Chuo":"io","Ci":"ci","Cong":"cs","Cou":"cz","Cu":"cu","Cuan":"cr","Cui":"cv","Cun":"cy","Cuo":"co","Da":"da","Dai":"dd","Dan":"dj","Dang":"dh","Dao":"dc","De":"de","Dei":"dw","Den":"df","Deng":"dg","Di":"di","Dia":"dx","Dian":"dm","Diao":"dn","Die":"dp","Ding":"dk","Diu":"dq","Dong":"ds","Dou":"dz","Du":"du","Duan":"dr","Dui":"dv","Dun":"dy","Duo":"do","Fa":"fa","Fan":"fj","Fang":"fh","Fei":"fw","Fen":"ff","Feng":"fg","Fiao":"fn","Fo":"fo","Fou":"fz","Fu":"fu","Ga":"ga","Gai":"gd","Gan":"gj","Gang":"gh","Gao":"gc","Ge":"ge","Gei":"gw","Gen":"gf","Geng":"gg","Gong":"gs","Gou":"gz","Gu":"gu","Gua":"gx","Guai":"gk","Guan":"gr","Guang":"gl","Gui":"gv","Gun":"gy","Guo":"go","Ha":"ha","Hai":"hd","Han":"hj","Hang":"hh","Hao":"hc","He":"he","Hei":"hw","Hen":"hf","Heng":"hg","Hong":"hs","Hou":"hz","Hu":"hu","Hua":"hx","Huai":"hk","Huan":"hr","Huang":"hl","Hui":"hv","Hun":"hy","Huo":"ho","Ji":"ji","Jia":"jx","Jian":"jm","Jiang":"jl","Jiao":"jn","Jie":"jp","Jin":"jb","Jing":"jk","Jiong":"js","Jiu":"jq","Ju":"ju","Juan":"jr","Jue":"jt","Jun":"jy","Ka":"ka","Kai":"kd","Kan":"kj","Kang":"kh","Kao":"kc","Ke":"ke","Ken":"kf","Keng":"kg","Kong":"ks","Kou":"kz","Ku":"ku","Kua":"kx","Kuai":"kk","Kuan":"kr","Kuang":"kl","Kui":"kv","Kun":"ky","Kuo":"ko","La":"la","Lai":"ld","Lan":"lj","Lang":"lh","Lao":"lc","Le":"le","Lei":"lw","Leng":"lg","Li":"li","Lia":"lx","Lian":"lm","Liang":"ll","Liao":"ln","Lie":"lp","Lin":"lb","Ling":"lk","Liu":"lq","Lo":"lo","Long":"ls","Lou":"lz","Lu":"lu","Luan":"lr","Lun":"ly","Luo":"lo","Ma":"ma","Mai":"md","Man":"mj","Mang":"mh","Mao":"mc","Me":"me","Mei":"mw","Men":"mf","Meng":"mg","Mi":"mi","Mian":"mm","Miao":"mn","Mie":"mp","Min":"mb","Ming":"mk","Miu":"mq","Mo":"mo","Mou":"mz","Mu":"mu","Na":"na","Nai":"nd","Nan":"nj","Nang":"nh","Nao":"nc","Ne":"ne","Nei":"nw","Nen":"nf","Neng":"ng","Ni":"ni","Nian":"nm","Niang":"nl","Niao":"nn","Nie":"np","Nin":"nb","Ning":"nk","Niu":"nq","Nong":"ns","Nou":"nz","Nu":"nu","Nuan":"nr","Nun":"ny","Nuo":"no","Pa":"pa","Pai":"pd","Pan":"pj","Pang":"ph","Pao":"pc","Pei":"pw","Pen":"pf","Peng":"pg","Pi":"pi","Pian":"pm","Piao":"pn","Pie":"pp","Pin":"pb","Ping":"pk","Po":"po","Pou":"pz","Pu":"pu","Qi":"qi","Qia":"qx","Qian":"qm","Qiang":"ql","Qiao":"qn","Qie":"qp","Qin":"qb","Qing":"qk","Qiong":"qs","Qiu":"qq","Qu":"qu","Quan":"qr","Que":"qt","Qun":"qy","Ran":"rj","Rang":"rh","Rao":"rc","Re":"re","Ren":"rf","Reng":"rg","Ri":"ri","Rong":"rs","Rou":"rz","Ru":"ru","Rua":"rx","Ruan":"rr","Rui":"rv","Run":"ry","Ruo":"ro","Sa":"sa","Sai":"sd","San":"sj","Sang":"sh","Sao":"sc","Se":"se","Sen":"sf","Seng":"sg","Sha":"ua","Shai":"ud","Shan":"uj","Shang":"uh","Shao":"uc","She":"ue","Shei":"uw","Shen":"uf","Sheng":"ug","Shi":"ui","Shou":"uz","Shu":"uu","Shua":"ux","Shuai":"uk","Shuan":"ur","Shuang":"ul","Shui":"uv","Shun":"uy","Shuo":"uo","Si":"si","Song":"ss","Sou":"sz","Su":"su","Suan":"sr","Sui":"sv","Sun":"sy","Suo":"so","Ta":"ta","Tai":"td","Tan":"tj","Tang":"th","Tao":"tc","Te":"te","Tei":"tw","Teng":"tg","Ti":"ti","Tian":"tm","Tiao":"tn","Tie":"tp","Ting":"tk","Tong":"ts","Tou":"tz","Tu":"tu","Tuan":"tr","Tui":"tv","Tun":"ty","Tuo":"to","Xi":"xi","Xia":"xx","Xian":"xm","Xiang":"xl","Xiao":"xn","Xie":"xp","Xin":"xb","Xing":"xk","Xiong":"xs","Xiu":"xq","Xu":"xu","Xuan":"xr","Xue":"xt","Xun":"xy","Za":"za","Zai":"zd","Zan":"zj","Zang":"zh","Zao":"zc","Ze":"ze","Zei":"zw","Zen":"zf","Zeng":"zg","Zha":"va","Zhai":"vd","Zhan":"vj","Zhang":"vh","Zhao":"vc","Zhe":"ve","Zhen":"vf","Zheng":"vg","Zhi":"vi","Zhong":"vs","Zhou":"vz","Zhu":"vu","Zhua":"vx","Zhuai":"vk","Zhuan":"vr","Zhuang":"vl","Zhui":"vv","Zhun":"vy","Zhuo":"vo","Zi":"zi","Zong":"zs","Zou":"zz","Zu":"zu","Zuan":"zr","Zui":"zv","Zun":"zy","Zuo":"zo"},"ZiRanMa":{"Lv":"lv","Lve":"lt","Lue":"lt","Nv":"nv","Nve":"nt","Nue":"nt","A":"aa","O":"oo","E":"ee","Ai":"ai","Ei":"ei","Ao":"ao","Ou":"ou","An":"an","En":"en","Ang":"ah","Eng":"eg","Er":"er","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yk","You":"yb","Yan":"yj","Yin":"yn","Yang":"yh","Ying":"yy","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wl","Wei":"wz","Wan":"wj","Wen":"wf","Wang":"wh","Weng":"wg","Yu":"yu","Yue":"yt","Yuan":"yr","Yun":"yp","Yong":"ys","Ba":"ba","Bai":"bl","Ban":"bj","Bang":"bh","Bao":"bk","Bei":"bz","Ben":"bf","Beng":"bg","Bi":"bi","Bian":"bm","Biang":"bd","Biao":"bc","Bie":"bx","Bin":"bn","Bing":"by","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cl","Can":"cj","Cang":"ch","Cao":"ck","Ce":"ce","Cen":"cf","Ceng":"cg","Cha":"ia","Chai":"il","Chan":"ij","Chang":"ih","Chao":"ik","Che":"ie","Chen":"if","Cheng":"ig","Chi":"ii","Chong":"is","Chou":"ib","Chu":"iu","Chua":"iw","Chuai":"iy","Chuan":"ir","Chuang":"id","Chui":"iv","Chun":"ip","Chuo":"io","Ci":"ci","Cong":"cs","Cou":"cb","Cu":"cu","Cuan":"cr","Cui":"cv","Cun":"cp","Cuo":"co","Da":"da","Dai":"dl","Dan":"dj","Dang":"dh","Dao":"dk","De":"de","Dei":"dz","Den":"df","Deng":"dg","Di":"di","Dia":"dw","Dian":"dm","Diao":"dc","Die":"dx","Ding":"dy","Diu":"dq","Dong":"ds","Dou":"db","Du":"du","Duan":"dr","Dui":"dv","Dun":"dp","Duo":"do","Fa":"fa","Fan":"fj","Fang":"fh","Fei":"fz","Fen":"ff","Feng":"fg","Fiao":"fc","Fo":"fo","Fou":"fb","Fu":"fu","Ga":"ga","Gai":"gl","Gan":"gj","Gang":"gh","Gao":"gk","Ge":"ge","Gei":"gz","Gen":"gf","Geng":"gg","Gong":"gs","Gou":"gb","Gu":"gu","Gua":"gw","Guai":"gy","Guan":"gr","Guang":"gd","Gui":"gv","Gun":"gp","Guo":"go","Ha":"ha","Hai":"hl","Han":"hj","Hang":"hh","Hao":"hk","He":"he","Hei":"hz","Hen":"hf","Heng":"hg","Hong":"hs","Hou":"hb","Hu":"hu","Hua":"hw","Huai":"hy","Huan":"hr","Huang":"hd","Hui":"hv","Hun":"hp","Huo":"ho","Ji":"ji","Jia":"jw","Jian":"jm","Jiang":"jd","Jiao":"jc","Jie":"jx","Jin":"jn","Jing":"jy","Jiong":"js","Jiu":"jq","Ju":"ju","Juan":"jr","Jue":"jt","Jun":"jp","Ka":"ka","Kai":"kl","Kan":"kj","Kang":"kh","Kao":"kk","Ke":"ke","Ken":"kf","Keng":"kg","Kong":"ks","Kou":"kb","Ku":"ku","Kua":"kw","Kuai":"ky","Kuan":"kr","Kuang":"kd","Kui":"kv","Kun":"kp","Kuo":"ko","La":"la","Lai":"ll","Lan":"lj","Lang":"lh","Lao":"lk","Le":"le","Lei":"lz","Leng":"lg","Li":"li","Lia":"lw","Lian":"lm","Liang":"ld","Liao":"lc","Lie":"lx","Lin":"ln","Ling":"ly","Liu":"lq","Lo":"lo","Long":"ls","Lou":"lb","Lu":"lu","Luan":"lr","Lun":"lp","Luo":"lo","Ma":"ma","Mai":"ml","Man":"mj","Mang":"mh","Mao":"mk","Me":"me","Mei":"mz","Men":"mf","Meng":"mg","Mi":"mi","Mian":"mm","Miao":"mc","Mie":"mx","Min":"mn","Ming":"my","Miu":"mq","Mo":"mo","Mou":"mb","Mu":"mu","Na":"na","Nai":"nl","Nan":"nj","Nang":"nh","Nao":"nk","Ne":"ne","Nei":"nz","Nen":"nf","Neng":"ng","Ni":"ni","Nian":"nm","Niang":"nd","Niao":"nc","Nie":"nx","Nin":"nn","Ning":"ny","Niu":"nq","Nong":"ns","Nou":"nb","Nu":"nu","Nuan":"nr","Nun":"np","Nuo":"no","Pa":"pa","Pai":"pl","Pan":"pj","Pang":"ph","Pao":"pk","Pei":"pz","Pen":"pf","Peng":"pg","Pi":"pi","Pian":"pm","Piao":"pc","Pie":"px","Pin":"pn","Ping":"py","Po":"po","Pou":"pb","Pu":"pu","Qi":"qi","Qia":"qw","Qian":"qm","Qiang":"qd","Qiao":"qc","Qie":"qx","Qin":"qn","Qing":"qy","Qiong":"qs","Qiu":"qq","Qu":"qu","Quan":"qr","Que":"qt","Qun":"qp","Ran":"rj","Rang":"rh","Rao":"rk","Re":"re","Ren":"rf","Reng":"rg","Ri":"ri","Rong":"rs","Rou":"rb","Ru":"ru","Rua":"rw","Ruan":"rr","Rui":"rv","Run":"rp","Ruo":"ro","Sa":"sa","Sai":"sl","San":"sj","Sang":"sh","Sao":"sk","Se":"se","Sen":"sf","Seng":"sg","Sha":"ua","Shai":"ul","Shan":"uj","Shang":"uh","Shao":"uk","She":"ue","Shei":"uz","Shen":"uf","Sheng":"ug","Shi":"ui","Shou":"ub","Shu":"uu","Shua":"uw","Shuai":"uy","Shuan":"ur","Shuang":"ud","Shui":"uv","Shun":"up","Shuo":"uo","Si":"si","Song":"ss","Sou":"sb","Su":"su","Suan":"sr","Sui":"sv","Sun":"sp","Suo":"so","Ta":"ta","Tai":"tl","Tan":"tj","Tang":"th","Tao":"tk","Te":"te","Tei":"tz","Teng":"tg","Ti":"ti","Tian":"tm","Tiao":"tc","Tie":"tx","Ting":"ty","Tong":"ts","Tou":"tb","Tu":"tu","Tuan":"tr","Tui":"tv","Tun":"tp","Tuo":"to","Xi":"xi","Xia":"xw","Xian":"xm","Xiang":"xd","Xiao":"xc","Xie":"xx","Xin":"xn","Xing":"xy","Xiong":"xs","Xiu":"xq","Xu":"xu","Xuan":"xr","Xue":"xt","Xun":"xp","Za":"za","Zai":"zl","Zan":"zj","Zang":"zh","Zao":"zk","Ze":"ze","Zei":"zz","Zen":"zf","Zeng":"zg","Zha":"va","Zhai":"vl","Zhan":"vj","Zhang":"vh","Zhao":"vk","Zhe":"ve","Zhen":"vf","Zheng":"vg","Zhi":"vi","Zhong":"vs","Zhou":"vb","Zhu":"vu","Zhua":"vw","Zhuai":"vy","Zhuan":"vr","Zhuang":"vd","Zhui":"vv","Zhun":"vp","Zhuo":"vo","Zi":"zi","Zong":"zs","Zou":"zb","Zu":"zu","Zuan":"zr","Zui":"zv","Zun":"zp","Zuo":"zo"},"WeiRuan":{"Lv":"ly","Lve":"lt","Lue":"lt","Nv":"ny","Nve":"nt","Nue":"nt","A":"oa","O":"oo","E":"oe","Ai":"ol","Ei":"oz","Ao":"ok","Ou":"ob","An":"oj","En":"of","Ang":"oh","Eng":"og","Er":"or","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yk","You":"yb","Yan":"yj","Yin":"yn","Yang":"yh","Ying":"y;","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wl","Wei":"wz","Wan":"wj","Wen":"wf","Wang":"wh","Weng":"wg","Yu":"yu","Yue":"yt","Yuan":"yr","Yun":"yp","Yong":"ys","Ba":"ba","Bai":"bl","Ban":"bj","Bang":"bh","Bao":"bk","Bei":"bz","Ben":"bf","Beng":"bg","Bi":"bi","Bian":"bm","Biang":"bd","Biao":"bc","Bie":"bx","Bin":"bn","Bing":"b;","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cl","Can":"cj","Cang":"ch","Cao":"ck","Ce":"ce","Cen":"cf","Ceng":"cg","Cha":"ia","Chai":"il","Chan":"ij","Chang":"ih","Chao":"ik","Che":"ie","Chen":"if","Cheng":"ig","Chi":"ii","Chong":"is","Chou":"ib","Chu":"iu","Chua":"iw","Chuai":"iy","Chuan":"ir","Chuang":"id","Chui":"iv","Chun":"ip","Chuo":"io","Ci":"ci","Cong":"cs","Cou":"cb","Cu":"cu","Cuan":"cr","Cui":"cv","Cun":"cp","Cuo":"co","Da":"da","Dai":"dl","Dan":"dj","Dang":"dh","Dao":"dk","De":"de","Dei":"dz","Den":"df","Deng":"dg","Di":"di","Dia":"dw","Dian":"dm","Diao":"dc","Die":"dx","Ding":"d;","Diu":"dq","Dong":"ds","Dou":"db","Du":"du","Duan":"dr","Dui":"dv","Dun":"dp","Duo":"do","Fa":"fa","Fan":"fj","Fang":"fh","Fei":"fz","Fen":"ff","Feng":"fg","Fiao":"fc","Fo":"fo","Fou":"fb","Fu":"fu","Ga":"ga","Gai":"gl","Gan":"gj","Gang":"gh","Gao":"gk","Ge":"ge","Gei":"gz","Gen":"gf","Geng":"gg","Gong":"gs","Gou":"gb","Gu":"gu","Gua":"gw","Guai":"gy","Guan":"gr","Guang":"gd","Gui":"gv","Gun":"gp","Guo":"go","Ha":"ha","Hai":"hl","Han":"hj","Hang":"hh","Hao":"hk","He":"he","Hei":"hz","Hen":"hf","Heng":"hg","Hong":"hs","Hou":"hb","Hu":"hu","Hua":"hw","Huai":"hy","Huan":"hr","Huang":"hd","Hui":"hv","Hun":"hp","Huo":"ho","Ji":"ji","Jia":"jw","Jian":"jm","Jiang":"jd","Jiao":"jc","Jie":"jx","Jin":"jn","Jing":"j;","Jiong":"js","Jiu":"jq","Ju":"ju","Juan":"jr","Jue":"jt","Jun":"jp","Ka":"ka","Kai":"kl","Kan":"kj","Kang":"kh","Kao":"kk","Ke":"ke","Ken":"kf","Keng":"kg","Kong":"ks","Kou":"kb","Ku":"ku","Kua":"kw","Kuai":"ky","Kuan":"kr","Kuang":"kd","Kui":"kv","Kun":"kp","Kuo":"ko","La":"la","Lai":"ll","Lan":"lj","Lang":"lh","Lao":"lk","Le":"le","Lei":"lz","Leng":"lg","Li":"li","Lia":"lw","Lian":"lm","Liang":"ld","Liao":"lc","Lie":"lx","Lin":"ln","Ling":"l;","Liu":"lq","Lo":"lo","Long":"ls","Lou":"lb","Lu":"lu","Luan":"lr","Lun":"lp","Luo":"lo","Ma":"ma","Mai":"ml","Man":"mj","Mang":"mh","Mao":"mk","Me":"me","Mei":"mz","Men":"mf","Meng":"mg","Mi":"mi","Mian":"mm","Miao":"mc","Mie":"mx","Min":"mn","Ming":"m;","Miu":"mq","Mo":"mo","Mou":"mb","Mu":"mu","Na":"na","Nai":"nl","Nan":"nj","Nang":"nh","Nao":"nk","Ne":"ne","Nei":"nz","Nen":"nf","Neng":"ng","Ni":"ni","Nian":"nm","Niang":"nd","Niao":"nc","Nie":"nx","Nin":"nn","Ning":"n;","Niu":"nq","Nong":"ns","Nou":"nb","Nu":"nu","Nuan":"nr","Nun":"np","Nuo":"no","Pa":"pa","Pai":"pl","Pan":"pj","Pang":"ph","Pao":"pk","Pei":"pz","Pen":"pf","Peng":"pg","Pi":"pi","Pian":"pm","Piao":"pc","Pie":"px","Pin":"pn","Ping":"p;","Po":"po","Pou":"pb","Pu":"pu","Qi":"qi","Qia":"qw","Qian":"qm","Qiang":"qd","Qiao":"qc","Qie":"qx","Qin":"qn","Qing":"q;","Qiong":"qs","Qiu":"qq","Qu":"qu","Quan":"qr","Que":"qt","Qun":"qp","Ran":"rj","Rang":"rh","Rao":"rk","Re":"re","Ren":"rf","Reng":"rg","Ri":"ri","Rong":"rs","Rou":"rb","Ru":"ru","Rua":"rw","Ruan":"rr","Rui":"rv","Run":"rp","Ruo":"ro","Sa":"sa","Sai":"sl","San":"sj","Sang":"sh","Sao":"sk","Se":"se","Sen":"sf","Seng":"sg","Sha":"ua","Shai":"ul","Shan":"uj","Shang":"uh","Shao":"uk","She":"ue","Shei":"uz","Shen":"uf","Sheng":"ug","Shi":"ui","Shou":"ub","Shu":"uu","Shua":"uw","Shuai":"uy","Shuan":"ur","Shuang":"ud","Shui":"uv","Shun":"up","Shuo":"uo","Si":"si","Song":"ss","Sou":"sb","Su":"su","Suan":"sr","Sui":"sv","Sun":"sp","Suo":"so","Ta":"ta","Tai":"tl","Tan":"tj","Tang":"th","Tao":"tk","Te":"te","Tei":"tz","Teng":"tg","Ti":"ti","Tian":"tm","Tiao":"tc","Tie":"tx","Ting":"t;","Tong":"ts","Tou":"tb","Tu":"tu","Tuan":"tr","Tui":"tv","Tun":"tp","Tuo":"to","Xi":"xi","Xia":"xw","Xian":"xm","Xiang":"xd","Xiao":"xc","Xie":"xx","Xin":"xn","Xing":"x;","Xiong":"xs","Xiu":"xq","Xu":"xu","Xuan":"xr","Xue":"xt","Xun":"xp","Za":"za","Zai":"zl","Zan":"zj","Zang":"zh","Zao":"zk","Ze":"ze","Zei":"zz","Zen":"zf","Zeng":"zg","Zha":"va","Zhai":"vl","Zhan":"vj","Zhang":"vh","Zhao":"vk","Zhe":"ve","Zhen":"vf","Zheng":"vg","Zhi":"vi","Zhong":"vs","Zhou":"vb","Zhu":"vu","Zhua":"vw","Zhuai":"vy","Zhuan":"vr","Zhuang":"vd","Zhui":"vv","Zhun":"vp","Zhuo":"vo","Zi":"zi","Zong":"zs","Zou":"zb","Zu":"zu","Zuan":"zr","Zui":"zv","Zun":"zp","Zuo":"zo"},"ZhiNengABC":{"Lv":"lv","Lve":"lm","Lue":"lm","Nv":"nv","Nve":"nm","Nue":"nm","A":"oa","O":"oo","E":"oe","Ai":"ol","Ei":"oq","Ao":"ok","Ou":"ob","An":"oj","En":"of","Ang":"oh","Eng":"og","Er":"or","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yk","You":"yb","Yan":"yj","Yin":"yc","Yang":"yh","Ying":"yy","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wl","Wei":"wq","Wan":"wj","Wen":"wf","Wang":"wh","Weng":"wg","Yu":"yu","Yue":"ym","Yuan":"yp","Yun":"yn","Yong":"ys","Ba":"ba","Bai":"bl","Ban":"bj","Bang":"bh","Bao":"bk","Bei":"bq","Ben":"bf","Beng":"bg","Bi":"bi","Bian":"bw","Biang":"bt","Biao":"bz","Bie":"bx","Bin":"bc","Bing":"by","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cl","Can":"cj","Cang":"ch","Cao":"ck","Ce":"ce","Cen":"cf","Ceng":"cg","Cha":"ea","Chai":"el","Chan":"ej","Chang":"eh","Chao":"ek","Che":"ee","Chen":"ef","Cheng":"eg","Chi":"ei","Chong":"es","Chou":"eb","Chu":"eu","Chua":"ed","Chuai":"ec","Chuan":"ep","Chuang":"et","Chui":"em","Chun":"en","Chuo":"eo","Ci":"ci","Cong":"cs","Cou":"cb","Cu":"cu","Cuan":"cp","Cui":"cm","Cun":"cn","Cuo":"co","Da":"da","Dai":"dl","Dan":"dj","Dang":"dh","Dao":"dk","De":"de","Dei":"dq","Den":"df","Deng":"dg","Di":"di","Dia":"dd","Dian":"dw","Diao":"dz","Die":"dx","Ding":"dy","Diu":"dr","Dong":"ds","Dou":"db","Du":"du","Duan":"dp","Dui":"dm","Dun":"dn","Duo":"do","Fa":"fa","Fan":"fj","Fang":"fh","Fei":"fq","Fen":"ff","Feng":"fg","Fiao":"fz","Fo":"fo","Fou":"fb","Fu":"fu","Ga":"ga","Gai":"gl","Gan":"gj","Gang":"gh","Gao":"gk","Ge":"ge","Gei":"gq","Gen":"gf","Geng":"gg","Gong":"gs","Gou":"gb","Gu":"gu","Gua":"gd","Guai":"gc","Guan":"gp","Guang":"gt","Gui":"gm","Gun":"gn","Guo":"go","Ha":"ha","Hai":"hl","Han":"hj","Hang":"hh","Hao":"hk","He":"he","Hei":"hq","Hen":"hf","Heng":"hg","Hong":"hs","Hou":"hb","Hu":"hu","Hua":"hd","Huai":"hc","Huan":"hp","Huang":"ht","Hui":"hm","Hun":"hn","Huo":"ho","Ji":"ji","Jia":"jd","Jian":"jw","Jiang":"jt","Jiao":"jz","Jie":"jx","Jin":"jc","Jing":"jy","Jiong":"js","Jiu":"jr","Ju":"ju","Juan":"jp","Jue":"jm","Jun":"jn","Ka":"ka","Kai":"kl","Kan":"kj","Kang":"kh","Kao":"kk","Ke":"ke","Ken":"kf","Keng":"kg","Kong":"ks","Kou":"kb","Ku":"ku","Kua":"kd","Kuai":"kc","Kuan":"kp","Kuang":"kt","Kui":"km","Kun":"kn","Kuo":"ko","La":"la","Lai":"ll","Lan":"lj","Lang":"lh","Lao":"lk","Le":"le","Lei":"lq","Leng":"lg","Li":"li","Lia":"ld","Lian":"lw","Liang":"lt","Liao":"lz","Lie":"lx","Lin":"lc","Ling":"ly","Liu":"lr","Lo":"lo","Long":"ls","Lou":"lb","Lu":"lu","Luan":"lp","Lun":"ln","Luo":"lo","Ma":"ma","Mai":"ml","Man":"mj","Mang":"mh","Mao":"mk","Me":"me","Mei":"mq","Men":"mf","Meng":"mg","Mi":"mi","Mian":"mw","Miao":"mz","Mie":"mx","Min":"mc","Ming":"my","Miu":"mr","Mo":"mo","Mou":"mb","Mu":"mu","Na":"na","Nai":"nl","Nan":"nj","Nang":"nh","Nao":"nk","Ne":"ne","Nei":"nq","Nen":"nf","Neng":"ng","Ni":"ni","Nian":"nw","Niang":"nt","Niao":"nz","Nie":"nx","Nin":"nc","Ning":"ny","Niu":"nr","Nong":"ns","Nou":"nb","Nu":"nu","Nuan":"np","Nun":"nn","Nuo":"no","Pa":"pa","Pai":"pl","Pan":"pj","Pang":"ph","Pao":"pk","Pei":"pq","Pen":"pf","Peng":"pg","Pi":"pi","Pian":"pw","Piao":"pz","Pie":"px","Pin":"pc","Ping":"py","Po":"po","Pou":"pb","Pu":"pu","Qi":"qi","Qia":"qd","Qian":"qw","Qiang":"qt","Qiao":"qz","Qie":"qx","Qin":"qc","Qing":"qy","Qiong":"qs","Qiu":"qr","Qu":"qu","Quan":"qp","Que":"qm","Qun":"qn","Ran":"rj","Rang":"rh","Rao":"rk","Re":"re","Ren":"rf","Reng":"rg","Ri":"ri","Rong":"rs","Rou":"rb","Ru":"ru","Rua":"rd","Ruan":"rp","Rui":"rm","Run":"rn","Ruo":"ro","Sa":"sa","Sai":"sl","San":"sj","Sang":"sh","Sao":"sk","Se":"se","Sen":"sf","Seng":"sg","Sha":"va","Shai":"vl","Shan":"vj","Shang":"vh","Shao":"vk","She":"ve","Shei":"vq","Shen":"vf","Sheng":"vg","Shi":"vi","Shou":"vb","Shu":"vu","Shua":"vd","Shuai":"vc","Shuan":"vp","Shuang":"vt","Shui":"vm","Shun":"vn","Shuo":"vo","Si":"si","Song":"ss","Sou":"sb","Su":"su","Suan":"sp","Sui":"sm","Sun":"sn","Suo":"so","Ta":"ta","Tai":"tl","Tan":"tj","Tang":"th","Tao":"tk","Te":"te","Tei":"tq","Teng":"tg","Ti":"ti","Tian":"tw","Tiao":"tz","Tie":"tx","Ting":"ty","Tong":"ts","Tou":"tb","Tu":"tu","Tuan":"tp","Tui":"tm","Tun":"tn","Tuo":"to","Xi":"xi","Xia":"xd","Xian":"xw","Xiang":"xt","Xiao":"xz","Xie":"xx","Xin":"xc","Xing":"xy","Xiong":"xs","Xiu":"xr","Xu":"xu","Xuan":"xp","Xue":"xm","Xun":"xn","Za":"za","Zai":"zl","Zan":"zj","Zang":"zh","Zao":"zk","Ze":"ze","Zei":"zq","Zen":"zf","Zeng":"zg","Zha":"aa","Zhai":"al","Zhan":"aj","Zhang":"ah","Zhao":"ak","Zhe":"ae","Zhen":"af","Zheng":"ag","Zhi":"ai","Zhong":"as","Zhou":"ab","Zhu":"au","Zhua":"ad","Zhuai":"ac","Zhuan":"ap","Zhuang":"at","Zhui":"am","Zhun":"an","Zhuo":"ao","Zi":"zi","Zong":"zs","Zou":"zb","Zu":"zu","Zuan":"zp","Zui":"zm","Zun":"zn","Zuo":"zo"},"ZiGuangPinYin":{"Lv":"lv","Lve":"ln","Lue":"ln","Nv":"nv","Nve":"nn","Nue":"nn","A":"oa","O":"oo","E":"oe","Ai":"op","Ei":"ok","Ao":"oq","Ou":"oz","An":"or","En":"ow","Ang":"os","Eng":"ot","Er":"oj","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yq","You":"yz","Yan":"yr","Yin":"yy","Yang":"ys","Ying":"yc","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wp","Wei":"wk","Wan":"wr","Wen":"ww","Wang":"ws","Weng":"wt","Yu":"yu","Yue":"yn","Yuan":"yl","Yun":"ym","Yong":"yh","Ba":"ba","Bai":"bp","Ban":"br","Bang":"bs","Bao":"bq","Bei":"bk","Ben":"bw","Beng":"bt","Bi":"bi","Bian":"bf","Biang":"bg","Biao":"bb","Bie":"bd","Bin":"by","Bing":"bc","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cp","Can":"cr","Cang":"cs","Cao":"cq","Ce":"ce","Cen":"cw","Ceng":"ct","Cha":"aa","Chai":"ap","Chan":"ar","Chang":"as","Chao":"aq","Che":"ae","Chen":"aw","Cheng":"at","Chi":"ai","Chong":"ah","Chou":"az","Chu":"au","Chua":"ax","Chuai":"ay","Chuan":"al","Chuang":"ag","Chui":"an","Chun":"am","Chuo":"ao","Ci":"ci","Cong":"ch","Cou":"cz","Cu":"cu","Cuan":"cl","Cui":"cn","Cun":"cm","Cuo":"co","Da":"da","Dai":"dp","Dan":"dr","Dang":"ds","Dao":"dq","De":"de","Dei":"dk","Den":"dw","Deng":"dt","Di":"di","Dia":"dx","Dian":"df","Diao":"db","Die":"dd","Ding":"dc","Diu":"dj","Dong":"dh","Dou":"dz","Du":"du","Duan":"dl","Dui":"dn","Dun":"dm","Duo":"do","Fa":"fa","Fan":"fr","Fang":"fs","Fei":"fk","Fen":"fw","Feng":"ft","Fiao":"fb","Fo":"fo","Fou":"fz","Fu":"fu","Ga":"ga","Gai":"gp","Gan":"gr","Gang":"gs","Gao":"gq","Ge":"ge","Gei":"gk","Gen":"gw","Geng":"gt","Gong":"gh","Gou":"gz","Gu":"gu","Gua":"gx","Guai":"gy","Guan":"gl","Guang":"gg","Gui":"gn","Gun":"gm","Guo":"go","Ha":"ha","Hai":"hp","Han":"hr","Hang":"hs","Hao":"hq","He":"he","Hei":"hk","Hen":"hw","Heng":"ht","Hong":"hh","Hou":"hz","Hu":"hu","Hua":"hx","Huai":"hy","Huan":"hl","Huang":"hg","Hui":"hn","Hun":"hm","Huo":"ho","Ji":"ji","Jia":"jx","Jian":"jf","Jiang":"jg","Jiao":"jb","Jie":"jd","Jin":"jy","Jing":"jc","Jiong":"jh","Jiu":"jj","Ju":"ju","Juan":"jl","Jue":"jn","Jun":"jm","Ka":"ka","Kai":"kp","Kan":"kr","Kang":"ks","Kao":"kq","Ke":"ke","Ken":"kw","Keng":"kt","Kong":"kh","Kou":"kz","Ku":"ku","Kua":"kx","Kuai":"ky","Kuan":"kl","Kuang":"kg","Kui":"kn","Kun":"km","Kuo":"ko","La":"la","Lai":"lp","Lan":"lr","Lang":"ls","Lao":"lq","Le":"le","Lei":"lk","Leng":"lt","Li":"li","Lia":"lx","Lian":"lf","Liang":"lg","Liao":"lb","Lie":"ld","Lin":"ly","Ling":"lc","Liu":"lj","Lo":"lo","Long":"lh","Lou":"lz","Lu":"lu","Luan":"ll","Lun":"lm","Luo":"lo","Ma":"ma","Mai":"mp","Man":"mr","Mang":"ms","Mao":"mq","Me":"me","Mei":"mk","Men":"mw","Meng":"mt","Mi":"mi","Mian":"mf","Miao":"mb","Mie":"md","Min":"my","Ming":"mc","Miu":"mj","Mo":"mo","Mou":"mz","Mu":"mu","Na":"na","Nai":"np","Nan":"nr","Nang":"ns","Nao":"nq","Ne":"ne","Nei":"nk","Nen":"nw","Neng":"nt","Ni":"ni","Nian":"nf","Niang":"ng","Niao":"nb","Nie":"nd","Nin":"ny","Ning":"nc","Niu":"nj","Nong":"nh","Nou":"nz","Nu":"nu","Nuan":"nl","Nun":"nm","Nuo":"no","Pa":"pa","Pai":"pp","Pan":"pr","Pang":"ps","Pao":"pq","Pei":"pk","Pen":"pw","Peng":"pt","Pi":"pi","Pian":"pf","Piao":"pb","Pie":"pd","Pin":"py","Ping":"pc","Po":"po","Pou":"pz","Pu":"pu","Qi":"qi","Qia":"qx","Qian":"qf","Qiang":"qg","Qiao":"qb","Qie":"qd","Qin":"qy","Qing":"qc","Qiong":"qh","Qiu":"qj","Qu":"qu","Quan":"ql","Que":"qn","Qun":"qm","Ran":"rr","Rang":"rs","Rao":"rq","Re":"re","Ren":"rw","Reng":"rt","Ri":"ri","Rong":"rh","Rou":"rz","Ru":"ru","Rua":"rx","Ruan":"rl","Rui":"rn","Run":"rm","Ruo":"ro","Sa":"sa","Sai":"sp","San":"sr","Sang":"ss","Sao":"sq","Se":"se","Sen":"sw","Seng":"st","Sha":"ia","Shai":"ip","Shan":"ir","Shang":"is","Shao":"iq","She":"ie","Shei":"ik","Shen":"iw","Sheng":"it","Shi":"ii","Shou":"iz","Shu":"iu","Shua":"ix","Shuai":"iy","Shuan":"il","Shuang":"ig","Shui":"in","Shun":"im","Shuo":"io","Si":"si","Song":"sh","Sou":"sz","Su":"su","Suan":"sl","Sui":"sn","Sun":"sm","Suo":"so","Ta":"ta","Tai":"tp","Tan":"tr","Tang":"ts","Tao":"tq","Te":"te","Tei":"tk","Teng":"tt","Ti":"ti","Tian":"tf","Tiao":"tb","Tie":"td","Ting":"tc","Tong":"th","Tou":"tz","Tu":"tu","Tuan":"tl","Tui":"tn","Tun":"tm","Tuo":"to","Xi":"xi","Xia":"xx","Xian":"xf","Xiang":"xg","Xiao":"xb","Xie":"xd","Xin":"xy","Xing":"xc","Xiong":"xh","Xiu":"xj","Xu":"xu","Xuan":"xl","Xue":"xn","Xun":"xm","Za":"za","Zai":"zp","Zan":"zr","Zang":"zs","Zao":"zq","Ze":"ze","Zei":"zk","Zen":"zw","Zeng":"zt","Zha":"ua","Zhai":"up","Zhan":"ur","Zhang":"us","Zhao":"uq","Zhe":"ue","Zhen":"uw","Zheng":"ut","Zhi":"ui","Zhong":"uh","Zhou":"uz","Zhu":"uu","Zhua":"ux","Zhuai":"uy","Zhuan":"ul","Zhuang":"ug","Zhui":"un","Zhun":"um","Zhuo":"uo","Zi":"zi","Zong":"zh","Zou":"zz","Zu":"zu","Zuan":"zl","Zui":"zn","Zun":"zm","Zuo":"zo"},"PinYinJiaJia":{"Lv":"lv","Lve":"lx","Lue":"lx","Nv":"nv","Nve":"nx","Nue":"nx","A":"aa","O":"oo","E":"ee","Ai":"as","Ei":"ew","Ao":"ad","Ou":"op","An":"af","En":"er","Ang":"ag","Eng":"et","Er":"eq","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"yd","You":"yp","Yan":"yf","Yin":"yl","Yang":"yg","Ying":"yq","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"ws","Wei":"ww","Wan":"wf","Wen":"wr","Wang":"wg","Weng":"wt","Yu":"yu","Yue":"yx","Yuan":"yc","Yun":"yz","Yong":"yy","Ba":"ba","Bai":"bs","Ban":"bf","Bang":"bg","Bao":"bd","Bei":"bw","Ben":"br","Beng":"bt","Bi":"bi","Bian":"bj","Biang":"bh","Biao":"bk","Bie":"bm","Bin":"bl","Bing":"bq","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cs","Can":"cf","Cang":"cg","Cao":"cd","Ce":"ce","Cen":"cr","Ceng":"ct","Cha":"ua","Chai":"us","Chan":"uf","Chang":"ug","Chao":"ud","Che":"ue","Chen":"ur","Cheng":"ut","Chi":"ui","Chong":"uy","Chou":"up","Chu":"uu","Chua":"ub","Chuai":"ux","Chuan":"uc","Chuang":"uh","Chui":"uv","Chun":"uz","Chuo":"uo","Ci":"ci","Cong":"cy","Cou":"cp","Cu":"cu","Cuan":"cc","Cui":"cv","Cun":"cz","Cuo":"co","Da":"da","Dai":"ds","Dan":"df","Dang":"dg","Dao":"dd","De":"de","Dei":"dw","Den":"dr","Deng":"dt","Di":"di","Dia":"db","Dian":"dj","Diao":"dk","Die":"dm","Ding":"dq","Diu":"dn","Dong":"dy","Dou":"dp","Du":"du","Duan":"dc","Dui":"dv","Dun":"dz","Duo":"do","Fa":"fa","Fan":"ff","Fang":"fg","Fei":"fw","Fen":"fr","Feng":"ft","Fiao":"fk","Fo":"fo","Fou":"fp","Fu":"fu","Ga":"ga","Gai":"gs","Gan":"gf","Gang":"gg","Gao":"gd","Ge":"ge","Gei":"gw","Gen":"gr","Geng":"gt","Gong":"gy","Gou":"gp","Gu":"gu","Gua":"gb","Guai":"gx","Guan":"gc","Guang":"gh","Gui":"gv","Gun":"gz","Guo":"go","Ha":"ha","Hai":"hs","Han":"hf","Hang":"hg","Hao":"hd","He":"he","Hei":"hw","Hen":"hr","Heng":"ht","Hong":"hy","Hou":"hp","Hu":"hu","Hua":"hb","Huai":"hx","Huan":"hc","Huang":"hh","Hui":"hv","Hun":"hz","Huo":"ho","Ji":"ji","Jia":"jb","Jian":"jj","Jiang":"jh","Jiao":"jk","Jie":"jm","Jin":"jl","Jing":"jq","Jiong":"jy","Jiu":"jn","Ju":"ju","Juan":"jc","Jue":"jx","Jun":"jz","Ka":"ka","Kai":"ks","Kan":"kf","Kang":"kg","Kao":"kd","Ke":"ke","Ken":"kr","Keng":"kt","Kong":"ky","Kou":"kp","Ku":"ku","Kua":"kb","Kuai":"kx","Kuan":"kc","Kuang":"kh","Kui":"kv","Kun":"kz","Kuo":"ko","La":"la","Lai":"ls","Lan":"lf","Lang":"lg","Lao":"ld","Le":"le","Lei":"lw","Leng":"lt","Li":"li","Lia":"lb","Lian":"lj","Liang":"lh","Liao":"lk","Lie":"lm","Lin":"ll","Ling":"lq","Liu":"ln","Lo":"lo","Long":"ly","Lou":"lp","Lu":"lu","Luan":"lc","Lun":"lz","Luo":"lo","Ma":"ma","Mai":"ms","Man":"mf","Mang":"mg","Mao":"md","Me":"me","Mei":"mw","Men":"mr","Meng":"mt","Mi":"mi","Mian":"mj","Miao":"mk","Mie":"mm","Min":"ml","Ming":"mq","Miu":"mn","Mo":"mo","Mou":"mp","Mu":"mu","Na":"na","Nai":"ns","Nan":"nf","Nang":"ng","Nao":"nd","Ne":"ne","Nei":"nw","Nen":"nr","Neng":"nt","Ni":"ni","Nian":"nj","Niang":"nh","Niao":"nk","Nie":"nm","Nin":"nl","Ning":"nq","Niu":"nn","Nong":"ny","Nou":"np","Nu":"nu","Nuan":"nc","Nun":"nz","Nuo":"no","Pa":"pa","Pai":"ps","Pan":"pf","Pang":"pg","Pao":"pd","Pei":"pw","Pen":"pr","Peng":"pt","Pi":"pi","Pian":"pj","Piao":"pk","Pie":"pm","Pin":"pl","Ping":"pq","Po":"po","Pou":"pp","Pu":"pu","Qi":"qi","Qia":"qb","Qian":"qj","Qiang":"qh","Qiao":"qk","Qie":"qm","Qin":"ql","Qing":"qq","Qiong":"qy","Qiu":"qn","Qu":"qu","Quan":"qc","Que":"qx","Qun":"qz","Ran":"rf","Rang":"rg","Rao":"rd","Re":"re","Ren":"rr","Reng":"rt","Ri":"ri","Rong":"ry","Rou":"rp","Ru":"ru","Rua":"rb","Ruan":"rc","Rui":"rv","Run":"rz","Ruo":"ro","Sa":"sa","Sai":"ss","San":"sf","Sang":"sg","Sao":"sd","Se":"se","Sen":"sr","Seng":"st","Sha":"ia","Shai":"is","Shan":"if","Shang":"ig","Shao":"id","She":"ie","Shei":"iw","Shen":"ir","Sheng":"it","Shi":"ii","Shou":"ip","Shu":"iu","Shua":"ib","Shuai":"ix","Shuan":"ic","Shuang":"ih","Shui":"iv","Shun":"iz","Shuo":"io","Si":"si","Song":"sy","Sou":"sp","Su":"su","Suan":"sc","Sui":"sv","Sun":"sz","Suo":"so","Ta":"ta","Tai":"ts","Tan":"tf","Tang":"tg","Tao":"td","Te":"te","Tei":"tw","Teng":"tt","Ti":"ti","Tian":"tj","Tiao":"tk","Tie":"tm","Ting":"tq","Tong":"ty","Tou":"tp","Tu":"tu","Tuan":"tc","Tui":"tv","Tun":"tz","Tuo":"to","Xi":"xi","Xia":"xb","Xian":"xj","Xiang":"xh","Xiao":"xk","Xie":"xm","Xin":"xl","Xing":"xq","Xiong":"xy","Xiu":"xn","Xu":"xu","Xuan":"xc","Xue":"xx","Xun":"xz","Za":"za","Zai":"zs","Zan":"zf","Zang":"zg","Zao":"zd","Ze":"ze","Zei":"zw","Zen":"zr","Zeng":"zt","Zha":"va","Zhai":"vs","Zhan":"vf","Zhang":"vg","Zhao":"vd","Zhe":"ve","Zhen":"vr","Zheng":"vt","Zhi":"vi","Zhong":"vy","Zhou":"vp","Zhu":"vu","Zhua":"vb","Zhuai":"vx","Zhuan":"vc","Zhuang":"vh","Zhui":"vv","Zhun":"vz","Zhuo":"vo","Zi":"zi","Zong":"zy","Zou":"zp","Zu":"zu","Zuan":"zc","Zui":"zv","Zun":"zz","Zuo":"zo"},"XingKongJianDao":{"Lv":"lv","Lve":"ly","Lue":"ly","Nv":"nv","Nve":"ny","Nue":"ny","A":"xa","O":"xo","E":"xe","Ai":"xj","Ei":"xw","Ao":"xs","Ou":"xt","An":"xd","En":"xk","Ang":"xf","Eng":"xh","Er":"xu","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"ys","You":"yt","Yan":"yd","Yin":"yb","Yang":"yf","Ying":"yg","Wu":"wj","Wa":"ws","Wo":"wo","Wai":"wh","Wei":"ww","Wan":"wf","Wen":"wn","Wang":"wp","Weng":"wr","Yu":"yv","Yue":"yy","Yuan":"yr","Yun":"yw","Yong":"yl","Ba":"ba","Bai":"bj","Ban":"bd","Bang":"bf","Bao":"bs","Bei":"bw","Ben":"bk","Beng":"bh","Bi":"bi","Bian":"bm","Biang":"bx","Biao":"bp","Bie":"bc","Bin":"bb","Bing":"bg","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"cj","Can":"cd","Cang":"cf","Cao":"cs","Ce":"ce","Cen":"ck","Ceng":"ch","Cha":"ja","Chai":"jj","Chan":"jd","Chang":"jf","Chao":"js","Che":"je","Chen":"jk","Cheng":"jh","Chi":"wi","Chong":"wl","Chou":"jt","Chu":"ju","Chua":"wx","Chuai":"wg","Chuan":"wr","Chuang":"wn","Chui":"wy","Chun":"jz","Chuo":"jo","Ci":"ci","Cong":"cl","Cou":"ct","Cu":"cu","Cuan":"cr","Cui":"cy","Cun":"cz","Cuo":"co","Da":"da","Dai":"dj","Dan":"dd","Dang":"df","Dao":"ds","De":"de","Dei":"dw","Den":"dk","Deng":"dh","Di":"di","Dia":"dx","Dian":"dm","Diao":"dp","Die":"dc","Ding":"dg","Diu":"dq","Dong":"dl","Dou":"dt","Du":"du","Duan":"dr","Dui":"dy","Dun":"dz","Duo":"do","Fa":"fs","Fan":"ff","Fang":"fp","Fei":"fw","Fen":"fn","Feng":"fr","Fiao":"fp","Fo":"fl","Fou":"fd","Fu":"fl","Ga":"ga","Gai":"gj","Gan":"gd","Gang":"gf","Gao":"gs","Ge":"ge","Gei":"gw","Gen":"gk","Geng":"gh","Gong":"gl","Gou":"gt","Gu":"gu","Gua":"gx","Guai":"gg","Guan":"gr","Guang":"gn","Gui":"gy","Gun":"gz","Guo":"go","Ha":"ha","Hai":"hj","Han":"hd","Hang":"hf","Hao":"hs","He":"he","Hei":"hw","Hen":"hk","Heng":"hh","Hong":"hl","Hou":"ht","Hu":"hu","Hua":"hx","Huai":"hg","Huan":"hr","Huang":"hn","Hui":"hy","Hun":"hz","Huo":"ho","Ji":"jk","Jia":"js","Jian":"jm","Jiang":"jn","Jiao":"jp","Jie":"jc","Jin":"jb","Jing":"jg","Jiong":"jy","Jiu":"jq","Ju":"jv","Juan":"jt","Jue":"jh","Jun":"jw","Ka":"ka","Kai":"kj","Kan":"kd","Kang":"kf","Kao":"ks","Ke":"ke","Ken":"kk","Keng":"kh","Kong":"kl","Kou":"kt","Ku":"ku","Kua":"kx","Kuai":"kg","Kuan":"kr","Kuang":"kn","Kui":"ky","Kun":"kz","Kuo":"ko","La":"la","Lai":"lj","Lan":"ld","Lang":"lf","Lao":"ls","Le":"le","Lei":"lw","Leng":"lh","Li":"li","Lia":"lx","Lian":"lm","Liang":"ln","Liao":"lp","Lie":"lc","Lin":"lb","Ling":"lg","Liu":"lq","Lo":"ll","Long":"ll","Lou":"lt","Lu":"lu","Luan":"lr","Lun":"lz","Luo":"lo","Ma":"ma","Mai":"mj","Man":"md","Mang":"mf","Mao":"ms","Me":"me","Mei":"mw","Men":"mk","Meng":"mh","Mi":"mi","Mian":"mm","Miao":"mp","Mie":"mc","Min":"mb","Ming":"mg","Miu":"mq","Mo":"mo","Mou":"mt","Mu":"mu","Na":"na","Nai":"nj","Nan":"nd","Nang":"nf","Nao":"ns","Ne":"ne","Nei":"nw","Nen":"nk","Neng":"nh","Ni":"ni","Nian":"nm","Niang":"nn","Niao":"np","Nie":"nc","Nin":"nb","Ning":"ng","Niu":"nq","Nong":"nl","Nou":"nt","Nu":"nu","Nuan":"nr","Nun":"nz","Nuo":"no","Pa":"pa","Pai":"pj","Pan":"pd","Pang":"pf","Pao":"ps","Pei":"pw","Pen":"pk","Peng":"ph","Pi":"pi","Pian":"pm","Piao":"pp","Pie":"pc","Pin":"pb","Ping":"pg","Po":"po","Pou":"pt","Pu":"pu","Qi":"qk","Qia":"qs","Qian":"qm","Qiang":"qx","Qiao":"qp","Qie":"qc","Qin":"qb","Qing":"qg","Qiong":"qy","Qiu":"qq","Qu":"qv","Quan":"qt","Que":"qh","Qun":"qw","Ran":"rd","Rang":"rf","Rao":"rs","Re":"re","Ren":"rk","Reng":"rh","Ri":"ri","Rong":"rl","Rou":"rt","Ru":"ru","Rua":"rx","Ruan":"rr","Rui":"ry","Run":"rz","Ruo":"ro","Sa":"sa","Sai":"sj","San":"sd","Sang":"sf","Sao":"ss","Se":"se","Sen":"sk","Seng":"sh","Sha":"ea","Shai":"ej","Shan":"ed","Shang":"ef","Shao":"es","She":"ee","Shei":"ew","Shen":"ek","Sheng":"eh","Shi":"ei","Shou":"et","Shu":"eu","Shua":"ex","Shuai":"eg","Shuan":"er","Shuang":"en","Shui":"ey","Shun":"ez","Shuo":"eo","Si":"si","Song":"sl","Sou":"st","Su":"su","Suan":"sr","Sui":"sy","Sun":"sz","Suo":"so","Ta":"ta","Tai":"tj","Tan":"td","Tang":"tf","Tao":"ts","Te":"te","Tei":"tw","Teng":"th","Ti":"ti","Tian":"tm","Tiao":"tp","Tie":"tc","Ting":"tg","Tong":"tl","Tou":"tt","Tu":"tu","Tuan":"tr","Tui":"ty","Tun":"tz","Tuo":"to","Xi":"xi","Xia":"xx","Xian":"xm","Xiang":"xn","Xiao":"xp","Xie":"xc","Xin":"xb","Xing":"xg","Xiong":"xl","Xiu":"xq","Xu":"xv","Xuan":"xr","Xue":"xy","Xun":"xw","Za":"za","Zai":"zj","Zan":"zd","Zang":"zf","Zao":"zs","Ze":"ze","Zei":"zw","Zen":"zk","Zeng":"zh","Zha":"qa","Zhai":"fj","Zhan":"qd","Zhang":"qf","Zhao":"fs","Zhe":"fe","Zhen":"qk","Zheng":"qh","Zhi":"fi","Zhong":"fy","Zhou":"qt","Zhu":"qu","Zhua":"fx","Zhuai":"fg","Zhuan":"fr","Zhuang":"fn","Zhui":"fy","Zhun":"fz","Zhuo":"qo","Zi":"zi","Zong":"zl","Zou":"zt","Zu":"zu","Zuan":"zr","Zui":"zy","Zun":"zz","Zuo":"zo"},"DaNiu":{"Lv":"lv","Lve":"lx","Lue":"lx","Nv":"nv","Nve":"nx","Nue":"nx","A":"ea","O":"eo","E":"ee","Ai":"eh","Ei":"ew","Ao":"es","Ou":"er","An":"ed","En":"ek","Ang":"ef","Eng":"ej","Er":"eu","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"ys","You":"yr","Yan":"yd","Yin":"yb","Yang":"yf","Ying":"yg","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wh","Wei":"ww","Wan":"wd","Wen":"wk","Wang":"wf","Weng":"wj","Yu":"yu","Yue":"yh","Yuan":"yj","Yun":"yw","Yong":"yl","Ba":"ba","Bai":"bh","Ban":"bd","Bang":"bf","Bao":"bs","Bei":"bw","Ben":"bk","Beng":"bj","Bi":"bi","Bian":"bc","Biang":"bn","Biao":"bm","Bie":"bp","Bin":"bb","Bing":"bg","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"ch","Can":"cd","Cang":"cf","Cao":"cs","Ce":"ce","Cen":"ck","Ceng":"cj","Cha":"ia","Chai":"ih","Chan":"id","Chang":"if","Chao":"is","Che":"ie","Chen":"ik","Cheng":"ij","Chi":"ii","Chong":"il","Chou":"ir","Chu":"iu","Chua":"iq","Chuai":"ig","Chuan":"iz","Chuang":"ix","Chui":"in","Chun":"iy","Chuo":"io","Ci":"ci","Cong":"cl","Cou":"cr","Cu":"cu","Cuan":"cz","Cui":"cn","Cun":"cy","Cuo":"co","Da":"da","Dai":"dh","Dan":"dd","Dang":"df","Dao":"ds","De":"de","Dei":"dw","Den":"dk","Deng":"dj","Di":"di","Dia":"dk","Dian":"dc","Diao":"dm","Die":"dp","Ding":"dg","Diu":"dt","Dong":"dl","Dou":"dr","Du":"du","Duan":"dz","Dui":"dn","Dun":"dy","Duo":"do","Fa":"fa","Fan":"fd","Fang":"ff","Fei":"fw","Fen":"fk","Feng":"fj","Fiao":"fm","Fo":"fo","Fou":"fr","Fu":"fu","Ga":"ga","Gai":"gh","Gan":"gd","Gang":"gf","Gao":"gs","Ge":"ge","Gei":"gw","Gen":"gk","Geng":"gj","Gong":"gl","Gou":"gr","Gu":"gu","Gua":"gq","Guai":"gg","Guan":"gz","Guang":"gx","Gui":"gn","Gun":"gy","Guo":"go","Ha":"ha","Hai":"hh","Han":"hd","Hang":"hf","Hao":"hs","He":"he","Hei":"hw","Hen":"hk","Heng":"hj","Hong":"hl","Hou":"hr","Hu":"hu","Hua":"hq","Huai":"hg","Huan":"hz","Huang":"hx","Hui":"hn","Hun":"hy","Huo":"ho","Ji":"ji","Jia":"jk","Jian":"jc","Jiang":"jn","Jiao":"jm","Jie":"jp","Jin":"jb","Jing":"jg","Jiong":"jl","Jiu":"jt","Ju":"ju","Juan":"jj","Jue":"jh","Jun":"jw","Ka":"ka","Kai":"kh","Kan":"kd","Kang":"kf","Kao":"ks","Ke":"ke","Ken":"kk","Keng":"kj","Kong":"kl","Kou":"kr","Ku":"ku","Kua":"kq","Kuai":"kg","Kuan":"kz","Kuang":"kx","Kui":"kn","Kun":"ky","Kuo":"ko","La":"la","Lai":"lh","Lan":"ld","Lang":"lf","Lao":"ls","Le":"le","Lei":"lw","Leng":"lj","Li":"li","Lia":"lk","Lian":"lc","Liang":"ln","Liao":"lm","Lie":"lp","Lin":"lb","Ling":"lg","Liu":"lt","Lo":"lo","Long":"ll","Lou":"lr","Lu":"lu","Luan":"lz","Lun":"ly","Luo":"lo","Ma":"ma","Mai":"mh","Man":"md","Mang":"mf","Mao":"ms","Me":"me","Mei":"mw","Men":"mk","Meng":"mj","Mi":"mi","Mian":"mc","Miao":"mm","Mie":"mp","Min":"mb","Ming":"mg","Miu":"mt","Mo":"mo","Mou":"mr","Mu":"mu","Na":"na","Nai":"nh","Nan":"nd","Nang":"nf","Nao":"ns","Ne":"ne","Nei":"nw","Nen":"nk","Neng":"nj","Ni":"ni","Nian":"nc","Niang":"nn","Niao":"nm","Nie":"np","Nin":"nb","Ning":"ng","Niu":"nt","Nong":"nl","Nou":"nr","Nu":"nu","Nuan":"nz","Nun":"ny","Nuo":"no","Pa":"pa","Pai":"ph","Pan":"pd","Pang":"pf","Pao":"ps","Pei":"pw","Pen":"pk","Peng":"pj","Pi":"pi","Pian":"pc","Piao":"pm","Pie":"pp","Pin":"pb","Ping":"pg","Po":"po","Pou":"pr","Pu":"pu","Qi":"qi","Qia":"qk","Qian":"qc","Qiang":"qn","Qiao":"qm","Qie":"qp","Qin":"qb","Qing":"qg","Qiong":"ql","Qiu":"qt","Qu":"qu","Quan":"qj","Que":"qh","Qun":"qw","Ran":"rd","Rang":"rf","Rao":"rs","Re":"re","Ren":"rk","Reng":"rj","Ri":"ri","Rong":"rl","Rou":"rr","Ru":"ru","Rua":"rq","Ruan":"rz","Rui":"rn","Run":"ry","Ruo":"ro","Sa":"sa","Sai":"sh","San":"sd","Sang":"sf","Sao":"ss","Se":"se","Sen":"sk","Seng":"sj","Sha":"ua","Shai":"uh","Shan":"ud","Shang":"uf","Shao":"us","She":"ue","Shei":"uw","Shen":"uk","Sheng":"uj","Shi":"ui","Shou":"ur","Shu":"uu","Shua":"uq","Shuai":"ug","Shuan":"uz","Shuang":"ux","Shui":"un","Shun":"uy","Shuo":"uo","Si":"si","Song":"sl","Sou":"sr","Su":"su","Suan":"sz","Sui":"sn","Sun":"sy","Suo":"so","Ta":"ta","Tai":"th","Tan":"td","Tang":"tf","Tao":"ts","Te":"te","Tei":"tw","Teng":"tj","Ti":"ti","Tian":"tc","Tiao":"tm","Tie":"tp","Ting":"tg","Tong":"tl","Tou":"tr","Tu":"tu","Tuan":"tz","Tui":"tn","Tun":"ty","Tuo":"to","Xi":"xi","Xia":"xk","Xian":"xc","Xiang":"xn","Xiao":"xm","Xie":"xp","Xin":"xb","Xing":"xg","Xiong":"xl","Xiu":"xt","Xu":"xu","Xuan":"xj","Xue":"xh","Xun":"xw","Za":"za","Zai":"zh","Zan":"zd","Zang":"zf","Zao":"zs","Ze":"ze","Zei":"zw","Zen":"zk","Zeng":"zj","Zha":"aa","Zhai":"ah","Zhan":"ad","Zhang":"af","Zhao":"as","Zhe":"ae","Zhen":"ak","Zheng":"aj","Zhi":"ai","Zhong":"al","Zhou":"ar","Zhu":"au","Zhua":"aq","Zhuai":"ag","Zhuan":"az","Zhuang":"ax","Zhui":"an","Zhun":"ay","Zhuo":"ao","Zi":"zi","Zong":"zl","Zou":"zr","Zu":"zu","Zuan":"zz","Zui":"zn","Zun":"zy","Zuo":"zo"},"XiaoLang":{"Lv":"lx","Lve":"lb","Lue":"lb","Nv":"nx","Nve":"nb","Nue":"nb","A":"aa","O":"oo","E":"uu","Ai":"ai","Ei":"ui","Ao":"ao","Ou":"ou","An":"an","En":"un","Ang":"ah","Eng":"un","Er":"ur","Yi":"yi","Ya":"ya","Yo":"yo","Ye":"ye","Yao":"ys","You":"yr","Yan":"yj","Yin":"yd","Yang":"yh","Ying":"yv","Wu":"wu","Wa":"wa","Wo":"wo","Wai":"wk","Wei":"ww","Wan":"wj","Wen":"wm","Wang":"wh","Weng":"wn","Yu":"yu","Yue":"yb","Yuan":"yg","Yun":"yy","Yong":"yl","Ba":"ba","Bai":"bk","Ban":"bj","Bang":"bh","Bao":"bs","Bei":"bw","Ben":"bm","Beng":"bn","Bi":"bi","Bian":"bf","Biang":"bm","Biao":"bc","Bie":"bp","Bin":"bd","Bing":"bv","Bo":"bo","Bu":"bu","Ca":"ca","Cai":"ck","Can":"cj","Cang":"ch","Cao":"cs","Ce":"ce","Cen":"cm","Ceng":"cn","Cha":"ia","Chai":"ik","Chan":"ij","Chang":"ih","Chao":"is","Che":"ie","Chen":"im","Cheng":"in","Chi":"ii","Chong":"il","Chou":"ir","Chu":"iu","Chua":"if","Chuai":"iv","Chuan":"ig","Chuang":"iz","Chui":"id","Chun":"iy","Chuo":"io","Ci":"ci","Cong":"cl","Cou":"cr","Cu":"cu","Cuan":"cg","Cui":"cd","Cun":"cy","Cuo":"co","Da":"da","Dai":"dk","Dan":"dj","Dang":"dh","Dao":"ds","De":"de","Dei":"dw","Den":"dm","Deng":"dn","Di":"di","Dia":"dk","Dian":"df","Diao":"dc","Die":"dp","Ding":"dv","Diu":"dt","Dong":"dl","Dou":"dr","Du":"du","Duan":"dg","Dui":"dd","Dun":"dy","Duo":"do","Fa":"fa","Fan":"fj","Fang":"fh","Fei":"fw","Fen":"fm","Feng":"fn","Fiao":"fc","Fo":"fo","Fou":"fr","Fu":"fu","Ga":"ga","Gai":"gk","Gan":"gj","Gang":"gh","Gao":"gs","Ge":"ge","Gei":"gw","Gen":"gm","Geng":"gn","Gong":"gl","Gou":"gr","Gu":"gu","Gua":"gf","Guai":"gv","Guan":"gg","Guang":"gz","Gui":"gd","Gun":"gy","Guo":"go","Ha":"ha","Hai":"hk","Han":"hj","Hang":"hh","Hao":"hs","He":"he","Hei":"hw","Hen":"hm","Heng":"hn","Hong":"hl","Hou":"hr","Hu":"hu","Hua":"hf","Huai":"hv","Huan":"hg","Huang":"hz","Hui":"hd","Hun":"hy","Huo":"ho","Ji":"ji","Jia":"jk","Jian":"jf","Jiang":"jm","Jiao":"jc","Jie":"jp","Jin":"jd","Jing":"jv","Jiong":"jj","Jiu":"jt","Ju":"ju","Juan":"jg","Jue":"jb","Jun":"jy","Ka":"ka","Kai":"kk","Kan":"kj","Kang":"kh","Kao":"ks","Ke":"ke","Ken":"km","Keng":"kn","Kong":"kl","Kou":"kr","Ku":"ku","Kua":"kf","Kuai":"kv","Kuan":"kg","Kuang":"kz","Kui":"kd","Kun":"ky","Kuo":"ko","La":"la","Lai":"lk","Lan":"lj","Lang":"lh","Lao":"ls","Le":"le","Lei":"lw","Leng":"ln","Li":"li","Lia":"lk","Lian":"lf","Liang":"lm","Liao":"lc","Lie":"lp","Lin":"ld","Ling":"lv","Liu":"lt","Lo":"lo","Long":"ll","Lou":"lr","Lu":"lu","Luan":"lg","Lun":"ly","Luo":"lo","Ma":"ma","Mai":"mk","Man":"mj","Mang":"mh","Mao":"ms","Me":"me","Mei":"mw","Men":"mm","Meng":"mn","Mi":"mi","Mian":"mf","Miao":"mc","Mie":"mp","Min":"md","Ming":"mv","Miu":"mt","Mo":"mo","Mou":"mr","Mu":"mu","Na":"na","Nai":"nk","Nan":"nj","Nang":"nh","Nao":"ns","Ne":"ne","Nei":"nw","Nen":"nm","Neng":"nn","Ni":"ni","Nian":"nf","Niang":"nm","Niao":"nc","Nie":"np","Nin":"nd","Ning":"nv","Niu":"nt","Nong":"nl","Nou":"nr","Nu":"nu","Nuan":"ng","Nun":"ny","Nuo":"no","Pa":"pa","Pai":"pk","Pan":"pj","Pang":"ph","Pao":"ps","Pei":"pw","Pen":"pm","Peng":"pn","Pi":"pi","Pian":"pf","Piao":"pc","Pie":"pp","Pin":"pd","Ping":"pv","Po":"po","Pou":"pr","Pu":"pu","Qi":"qi","Qia":"qk","Qian":"qf","Qiang":"qm","Qiao":"qc","Qie":"qp","Qin":"qd","Qing":"qv","Qiong":"qj","Qiu":"qt","Qu":"qu","Quan":"qg","Que":"qb","Qun":"qy","Ran":"rj","Rang":"rh","Rao":"rs","Re":"re","Ren":"rm","Reng":"rn","Ri":"ri","Rong":"rl","Rou":"rr","Ru":"ru","Rua":"rf","Ruan":"rg","Rui":"rd","Run":"ry","Ruo":"ro","Sa":"sa","Sai":"sk","San":"sj","Sang":"sh","Sao":"ss","Se":"se","Sen":"sm","Seng":"sn","Sha":"va","Shai":"vk","Shan":"vj","Shang":"vh","Shao":"vs","She":"ve","Shei":"vw","Shen":"vm","Sheng":"vn","Shi":"vi","Shou":"vr","Shu":"vu","Shua":"vf","Shuai":"vv","Shuan":"vg","Shuang":"vz","Shui":"vd","Shun":"vy","Shuo":"vo","Si":"si","Song":"sl","Sou":"sr","Su":"su","Suan":"sg","Sui":"sd","Sun":"sy","Suo":"so","Ta":"ta","Tai":"tk","Tan":"tj","Tang":"th","Tao":"ts","Te":"te","Tei":"tw","Teng":"tn","Ti":"ti","Tian":"tf","Tiao":"tc","Tie":"tp","Ting":"tv","Tong":"tl","Tou":"tr","Tu":"tu","Tuan":"tg","Tui":"td","Tun":"ty","Tuo":"to","Xi":"xi","Xia":"xk","Xian":"xf","Xiang":"xm","Xiao":"xc","Xie":"xp","Xin":"xd","Xing":"xv","Xiong":"xj","Xiu":"xt","Xu":"xu","Xuan":"xg","Xue":"xb","Xun":"xy","Za":"za","Zai":"zk","Zan":"zj","Zang":"zh","Zao":"zs","Ze":"ze","Zei":"zw","Zen":"zm","Zeng":"zn","Zha":"ea","Zhai":"ek","Zhan":"ej","Zhang":"eh","Zhao":"es","Zhe":"ee","Zhen":"em","Zheng":"en","Zhi":"ei","Zhong":"el","Zhou":"er","Zhu":"eu","Zhua":"ef","Zhuai":"ev","Zhuan":"eg","Zhuang":"ez","Zhui":"ed","Zhun":"ey","Zhuo":"eo","Zi":"zi","Zong":"zl","Zou":"zr","Zu":"zu","Zuan":"zg","Zui":"zd","Zun":"zy","Zuo":"zo"}} \ No newline at end of file diff --git a/Flow.Launcher/ResultListBox.xaml b/Flow.Launcher/ResultListBox.xaml index e26340c4f..e469bb63b 100644 --- a/Flow.Launcher/ResultListBox.xaml +++ b/Flow.Launcher/ResultListBox.xaml @@ -32,8 +32,11 @@ + + + @@ -64,24 +67,25 @@ Grid.Column="2" Margin="0 0 10 0" VerticalAlignment="Center" - Visibility="{Binding ShowOpenResultHotkey}"> - - + Visibility="{Binding Settings.ShowOpenResultHotkey, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + + - - - - - - - - + + + + + + + + + + @@ -89,60 +93,64 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SelectBrowserWindow.xaml b/Flow.Launcher/SelectBrowserWindow.xaml index d8807dbef..67c22b07d 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" @@ -28,14 +29,11 @@ - - - + + + + + + + + + + + + + + + + + diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs index dfb4a7eaf..c0a77957a 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePluginStore.xaml.cs @@ -1,9 +1,8 @@ -using System; -using System.ComponentModel; +using System.ComponentModel; using System.Windows.Data; using System.Windows.Input; using System.Windows.Navigation; -using Flow.Launcher.Core.Plugin; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.SettingPages.ViewModels; using Flow.Launcher.ViewModel; @@ -12,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; @@ -29,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; } } @@ -49,7 +61,7 @@ public partial class SettingsPanePluginStore private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e) { - PluginManager.API.OpenUrl(e.Uri.AbsoluteUri); + App.API.OpenUrl(e.Uri.AbsoluteUri); e.Handled = true; } diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml index 37079a46f..aa7c219e5 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,97 @@ 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 PluginListBox_Loaded(object sender, RoutedEventArgs e) + { + // After list is loaded, we need to clear selection to make sure all items can be mouse hovered + // because the selected item cannot be hovered. + if (sender is not ListBox listBox) return; + listBox.SelectedIndex = -1; + } + + 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..7d894e1b3 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 @@ @@ -32,35 +32,35 @@ TextAlignment="left" /> - + - + - + - + - + - +