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/update_release_pr.py b/.github/update_release_pr.py index ccea511b3..be523bfe8 100644 --- a/.github/update_release_pr.py +++ b/.github/update_release_pr.py @@ -1,11 +1,12 @@ 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 milestone and label. + Fetches pull requests from a GitHub repository that match a given label and state. Args: token (str): GitHub token. @@ -23,39 +24,10 @@ def get_github_prs(token: str, owner: str, repo: str, label: str = "", state: st "Accept": "application/vnd.github.v3+json", } - milestone_id = None - milestone_url = f"https://api.github.com/repos/{owner}/{repo}/milestones" - params = {"state": "open"} - - try: - response = requests.get(milestone_url, headers=headers, params=params) - response.raise_for_status() - milestones = response.json() - - if len(milestones) > 2: - print("More than two milestones found, unable to determine the milestone required.") - exit(1) - - # milestones.pop() - for ms in milestones: - if ms["title"] != "Future": - milestone_id = ms["number"] - print(f"Gathering PRs with milestone {ms['title']}...") - break - - if not milestone_id: - print(f"No suitable milestone found in repository '{owner}/{repo}'.") - exit(1) - - except requests.exceptions.RequestException as e: - print(f"Error fetching milestones: {e}") - exit(1) - - # This endpoint allows filtering by milestone and label. A PR in GH's perspective is a type of issue. + # 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, - "milestone": milestone_id, "labels": label, "per_page": 100, } @@ -83,7 +55,9 @@ def get_github_prs(token: str, owner: str, repo: str, label: str = "", state: st return all_prs -def get_prs(pull_request_items: list[dict], label: str = "", state: str = "all") -> list[dict]: +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. @@ -91,6 +65,8 @@ def get_prs(pull_request_items: list[dict], label: str = "", state: str = "all") 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. @@ -99,22 +75,32 @@ def get_prs(pull_request_items: list[dict], label: str = "", state: str = "all") pr_list = [] count = 0 for pr in pull_request_items: - if state in [pr["state"], "all"] and (not label or [item for item in pr["labels"] if item["name"] == label]): - pr_list.append(pr) - count += 1 + if state not in [pr["state"], "all"]: + continue - print(f"Found {count} PRs with {label if label else 'no filter on'} label and state as {state}") + 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], label: str = "", state: str = "all") -> list[str]: + +def get_prs_assignees(pull_request_items: list[dict]) -> list[str]: """ - Returns a list of pull request assignees after applying the label and state filters, excludes jjw24. + Returns a list of pull request assignees, excludes jjw24. Args: - pull_request_items (list[dict]): List of PR items. - label (str): The label name. Filter is not applied when empty string. - state (str): State of PR, e.g. open, closed, all + 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 @@ -123,13 +109,13 @@ def get_prs_assignees(pull_request_items: list[dict], label: str = "", state: st """ assignee_list = [] for pr in pull_request_items: - if state in [pr["state"], "all"] and (not label or [item for item in pr["labels"] if item["name"] == label]): - [assignee_list.append(assignee["login"]) for assignee in pr["assignees"] if assignee["login"] != "jjw24" ] + [assignee_list.append(assignee["login"]) for assignee in pr["assignees"] if assignee["login"] != "jjw24"] - print(f"Found {len(assignee_list)} assignees with {label if label else 'no filter on'} label and state as {state}") + 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 @@ -207,15 +193,16 @@ if __name__ == "__main__": print(f"Fetching {state} PRs for {repository_owner}/{repository_name} ...") - pull_requests = get_github_prs(github_token, 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 pull_requests: - print("No matching pull requests found") + if not all_pull_requests: + print("No pull requests found") exit(1) - print(f"\nFound total of {len(pull_requests)} pull requests") + print(f"\nFound total of {len(all_pull_requests)} pull requests") - release_pr = get_prs(pull_requests, "release", "open") + 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}") @@ -223,14 +210,25 @@ if __name__ == "__main__": print(f"Found release PR: {release_pr[0]['title']}") - enhancement_prs = get_prs(pull_requests, "enhancement", "closed") - bug_fix_prs = get_prs(pull_requests, "bug", "closed") + 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(pull_requests, "enhancement", "closed") + get_prs_assignees(pull_requests, "bug", "closed"))) + 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)}" diff --git a/.github/workflows/default_plugins.yml b/.github/workflows/default_plugins.yml index ec8dfcd4e..83e830d75 100644 --- a/.github/workflows/default_plugins.yml +++ b/.github/workflows/default_plugins.yml @@ -10,11 +10,11 @@ jobs: 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: Update Plugins To Production Version run: | @@ -42,7 +42,7 @@ jobs: - name: Build BrowserBookmark 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" @@ -66,7 +66,7 @@ jobs: - name: Build Calculator 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" @@ -90,7 +90,7 @@ jobs: - name: Build Explorer 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" @@ -114,7 +114,7 @@ jobs: - name: Build PluginIndicator 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" @@ -138,7 +138,7 @@ jobs: - name: Build PluginsManager 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" @@ -162,7 +162,7 @@ jobs: - name: Build ProcessKiller 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" @@ -186,7 +186,7 @@ jobs: - name: Build Program 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" @@ -210,7 +210,7 @@ jobs: - name: Build Shell 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" @@ -234,7 +234,7 @@ jobs: - name: Build Sys 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" @@ -258,7 +258,7 @@ jobs: - name: Build Url 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" @@ -282,7 +282,7 @@ jobs: - name: Build WebSearch 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" @@ -306,7 +306,7 @@ jobs: - name: Build WindowsSettings 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" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7498262de..957f836bb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,11 +16,11 @@ jobs: runs-on: windows-latest env: - FlowVersion: 1.19.5 + FlowVersion: 1.20.2 NUGET_CERT_REVOCATION_MODE: offline BUILD_NUMBER: ${{ github.run_number }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set Flow.Launcher.csproj version id: update uses: vers-one/dotnet-project-version-updater@v1.7 @@ -29,9 +29,9 @@ jobs: "**/SolutionAssemblyInfo.cs" version: ${{ env.FlowVersion }}.${{ env.BUILD_NUMBER }} - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 7.0.x + dotnet-version: 9.0.x # cache: true # cache-dependency-path: | # Flow.Launcher/packages.lock.json diff --git a/.github/workflows/release_pr.yml b/.github/workflows/release_pr.yml index 451bf386c..58a877ba3 100644 --- a/.github/workflows/release_pr.yml +++ b/.github/workflows/release_pr.yml @@ -11,9 +11,9 @@ jobs: update-pr: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 47bd66107..904392bf0 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -72,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 @@ -128,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 7f02cef09..8b305263d 100644 --- a/Flow.Launcher.Core/Configuration/Portable.cs +++ b/Flow.Launcher.Core/Configuration/Portable.cs @@ -3,10 +3,8 @@ using System.IO; using System.Linq; using System.Reflection; using System.Windows; -using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; using Microsoft.Win32; using Squirrel; @@ -17,8 +15,6 @@ namespace Flow.Launcher.Core.Configuration { 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 /// @@ -45,14 +41,13 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.PortableDataPath); - API.ShowMsgBox("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"); + PublicApi.Instance.ShowMsgBox(Localize.restartToDisablePortableMode()); UpdateManager.RestartApp(Constant.ApplicationFileName); } catch (Exception e) { - API.LogException(ClassName, "Error occurred while disabling portable mode", e); + PublicApi.Instance.LogException(ClassName, "Error occurred while disabling portable mode", e); } } @@ -69,14 +64,13 @@ namespace Flow.Launcher.Core.Configuration #endif IndicateDeletion(DataLocation.RoamingDataPath); - API.ShowMsgBox("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"); + PublicApi.Instance.ShowMsgBox(Localize.restartToEnablePortableMode()); UpdateManager.RestartApp(Constant.ApplicationFileName); } catch (Exception e) { - API.LogException(ClassName, "Error occurred while enabling portable mode", e); + PublicApi.Instance.LogException(ClassName, "Error occurred while enabling portable mode", e); } } @@ -96,13 +90,13 @@ namespace Flow.Launcher.Core.Configuration public void MoveUserDataFolder(string fromLocation, string toLocation) { - FilesFolders.CopyAll(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); + FilesFolders.CopyAll(fromLocation, toLocation, (s) => PublicApi.Instance.ShowMsgBox(s)); VerifyUserDataAfterMove(fromLocation, toLocation); } public void VerifyUserDataAfterMove(string fromLocation, string toLocation) { - FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation, (s) => API.ShowMsgBox(s)); + FilesFolders.VerifyBothFolderFilesEqual(fromLocation, toLocation, (s) => PublicApi.Instance.ShowMsgBox(s)); } public void CreateShortcuts() @@ -152,13 +146,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, (s) => API.ShowMsgBox(s)); + FilesFolders.RemoveFolderIfExists(roamingDataDir, (s) => PublicApi.Instance.ShowMsgBox(s)); - if (API.ShowMsgBox("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 (PublicApi.Instance.ShowMsgBox(Localize.moveToDifferentLocation(), + string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes) { - FilesFolders.OpenPath(Constant.RootDirectory, (s) => API.ShowMsgBox(s)); + FilesFolders.OpenPath(Constant.RootDirectory, (s) => PublicApi.Instance.ShowMsgBox(s)); Environment.Exit(0); } @@ -167,10 +160,9 @@ namespace Flow.Launcher.Core.Configuration // delete it and notify the user about it. else if (File.Exists(portableDataDeleteFilePath)) { - FilesFolders.RemoveFolderIfExists(portableDataDir, (s) => API.ShowMsgBox(s)); + FilesFolders.RemoveFolderIfExists(portableDataDir, (s) => PublicApi.Instance.ShowMsgBox(s)); - API.ShowMsgBox("Flow Launcher has detected you disabled portable mode, " + - "the relevant shortcuts and uninstaller entry have been created"); + PublicApi.Instance.ShowMsgBox(Localize.shortcutsUninstallerCreated()); } } @@ -181,9 +173,7 @@ namespace Flow.Launcher.Core.Configuration if (roamingLocationExists && portableLocationExists) { - API.ShowMsgBox(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)); + PublicApi.Instance.ShowMsgBox(Localize.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 6f3b23e11..7c0290b2a 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -8,7 +8,6 @@ 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; @@ -18,13 +17,9 @@ namespace Flow.Launcher.Core.ExternalPlugins { 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 List plugins = []; private static readonly JsonSerializerOptions PluginStoreItemSerializationOption = new() { @@ -41,7 +36,7 @@ namespace Flow.Launcher.Core.ExternalPlugins /// public async Task> FetchAsync(CancellationToken token) { - API.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}"); + PublicApi.Instance.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}"); var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl); @@ -59,29 +54,40 @@ namespace Flow.Launcher.Core.ExternalPlugins .ConfigureAwait(false); latestEtag = response.Headers.ETag?.Tag; - API.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrl}"); + PublicApi.Instance.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."); + PublicApi.Instance.LogInfo(ClassName, $"Resource {ManifestFileUrl} has not been modified."); return plugins; } else { - API.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + PublicApi.Instance.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); return null; } } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + PublicApi.Instance.LogDebug(ClassName, $"Fetching from {ManifestFileUrl} was cancelled by caller."); + return null; + } + catch (TaskCanceledException) + { + // Likely an HttpClient timeout or external cancellation not requested by our token + PublicApi.Instance.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); + PublicApi.Instance.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrl}.", e); } else { - API.LogException(ClassName, "Error Occurred", e); + PublicApi.Instance.LogException(ClassName, "Error Occurred", e); } return null; } diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs index 14796a87a..1a324a993 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/AbstractPluginEnvironment.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Windows; using System.Windows.Forms; -using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; @@ -15,7 +14,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments { private static readonly string ClassName = nameof(AbstractPluginEnvironment); - protected readonly IPublicAPI API = Ioc.Default.GetRequiredService(); + protected readonly IPublicAPI API = PublicApi.Instance; internal abstract string Language { get; } @@ -58,15 +57,10 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments return SetPathForPluginPairs(PluginsSettingsFilePath, Language); } - var noRuntimeMessage = string.Format( - API.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"), - Language, - EnvName, - Environment.NewLine - ); + var noRuntimeMessage = Localize.runtimePluginInstalledChooseRuntimePrompt(Language, EnvName, Environment.NewLine); if (API.ShowMsgBox(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) { - var msg = string.Format(API.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName); + var msg = Localize.runtimePluginChooseRuntimeExecutable(EnvName); var selectedFile = GetFileFromDialog(msg, FileDialogFilter); @@ -77,12 +71,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments // Nothing selected because user pressed cancel from the file dialog window else { - var forceDownloadMessage = string.Format( - API.GetTranslation("runtimeExecutableInvalidChooseDownload"), - Language, - EnvName, - Environment.NewLine - ); + var forceDownloadMessage = Localize.runtimeExecutableInvalidChooseDownload(Language, EnvName, Environment.NewLine); // Let users select valid path or choose to download while (string.IsNullOrEmpty(selectedFile)) @@ -120,7 +109,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments } else { - API.ShowMsgBox(string.Format(API.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language)); + API.ShowMsgBox(Localize.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"); @@ -248,7 +237,7 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments private static string GetUpdatedEnvironmentPath(string filePath) { var index = filePath.IndexOf(DataLocation.PluginEnvironments); - + // get the substring after "Environments" because we can not determine it dynamically var executablePathSubstring = filePath[(index + DataLocation.PluginEnvironments.Length)..]; return $"{DataLocation.PluginEnvironmentsPath}{executablePathSubstring}"; diff --git a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs index 455ee096d..76c775fb4 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/PythonEnvironment.cs @@ -11,6 +11,8 @@ 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; @@ -39,9 +41,20 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments // 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 - JTF.Run(() => DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath)); + JTF.Run(async () => + { + try + { + await DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(Localize.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 12965286f..d8244cbf3 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptEnvironment.cs @@ -11,6 +11,8 @@ 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; @@ -34,9 +36,20 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments { FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); + JTF.Run(async () => + { + try + { + await DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(Localize.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 6960b79c9..e2de53e39 100644 --- a/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs +++ b/Flow.Launcher.Core/ExternalPlugins/Environments/TypeScriptV2Environment.cs @@ -11,6 +11,8 @@ 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; @@ -34,9 +36,20 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments { FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s)); - JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath)); + JTF.Run(async () => + { + try + { + await DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath); - PluginsSettingsFilePath = ExecutablePath; + PluginsSettingsFilePath = ExecutablePath; + } + catch (System.Exception e) + { + API.ShowMsgError(Localize.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 7ca91eaec..eab9a8c43 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -2,8 +2,8 @@ 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 { @@ -12,10 +12,10 @@ namespace Flow.Launcher.Core.ExternalPlugins 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); @@ -35,18 +35,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) { - Ioc.Default.GetRequiredService().LogException(ClassName, "Http request failed", e); + PublicApi.Instance.LogException(ClassName, "Http request failed", e); } finally { @@ -55,5 +65,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) + { + PublicApi.Instance.LogException(ClassName, $"Failed to parse the minimum app version {plugin.MinimumAppVersion} for plugin {plugin.Name}. " + + "Plugin excluded from manifest", e); + return false; + } + + PublicApi.Instance.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/Flow.Launcher.Core.csproj b/Flow.Launcher.Core/Flow.Launcher.Core.csproj index e9f199d00..52eaf0501 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 @@ -33,6 +34,7 @@ prompt 4 false + $(NoWarn);FLSG0007 @@ -54,11 +56,24 @@ - - + + + + - + + + + + true + + + + + + Languages\en.xaml + diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs index 435d97ab7..abefd47bc 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -27,6 +27,7 @@ namespace Flow.Launcher.Core.Plugin 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"); @@ -156,7 +157,7 @@ namespace Flow.Launcher.Core.Plugin { if (!NeedCreateSettingPanel()) return null!; - // Create main grid with two columns (Column 1: Auto, Column 2: *) + // 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() { @@ -200,7 +201,7 @@ namespace Flow.Launcher.Core.Plugin { Text = attributes.Label, VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.WrapWithOverflow + TextWrapping = TextWrapping.Wrap }; // Create a text block for description @@ -211,7 +212,7 @@ namespace Flow.Launcher.Core.Plugin { Text = attributes.Description, VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.WrapWithOverflow + TextWrapping = TextWrapping.Wrap }; desc.SetResourceReference(TextBlock.StyleProperty, "SettingPanelTextBlockDescriptionStyle"); // for theme change @@ -247,7 +248,8 @@ namespace Flow.Launcher.Core.Plugin VerticalAlignment = VerticalAlignment.Center, Margin = SettingPanelItemLeftTopBottomMargin, Text = Settings[attributes.Name] as string ?? string.Empty, - ToolTip = attributes.Description + ToolTip = attributes.Description, + TextWrapping = TextWrapping.Wrap }; textBox.TextChanged += (_, _) => @@ -269,7 +271,8 @@ namespace Flow.Launcher.Core.Plugin VerticalAlignment = VerticalAlignment.Center, Margin = SettingPanelItemLeftMargin, Text = Settings[attributes.Name] as string ?? string.Empty, - ToolTip = attributes.Description + ToolTip = attributes.Description, + TextWrapping = TextWrapping.Wrap }; textBox.TextChanged += (_, _) => @@ -282,7 +285,7 @@ namespace Flow.Launcher.Core.Plugin HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Center, Margin = SettingPanelItemLeftMargin, - Content = API.GetTranslation("select") + Content = Localize.select() }; Btn.Click += (_, _) => @@ -333,7 +336,7 @@ namespace Flow.Launcher.Core.Plugin HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Center, Margin = SettingPanelItemLeftTopBottomMargin, - TextWrapping = TextWrapping.WrapWithOverflow, + TextWrapping = TextWrapping.Wrap, AcceptsReturn = true, Text = Settings[attributes.Name] as string ?? string.Empty, ToolTip = attributes.Description @@ -488,6 +491,8 @@ namespace Flow.Launcher.Core.Plugin rowCount++; } + mainPanel.SizeChanged += MainPanel_SizeChanged; + // Wrap the main grid in a user control return new UserControl() { @@ -495,6 +500,28 @@ namespace Flow.Launcher.Core.Plugin }; } + 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/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index f7457b4e1..c5f0f79a7 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -5,7 +5,6 @@ using System.IO; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin; using System.Text.Json; -using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Plugin { @@ -13,10 +12,6 @@ namespace Flow.Launcher.Core.Plugin { 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 /// @@ -38,7 +33,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Can't delete <{directory}>", e); + PublicApi.Instance.LogException(ClassName, $"Can't delete <{directory}>", e); } } else @@ -55,7 +50,7 @@ namespace Flow.Launcher.Core.Plugin duplicateList .ForEach( - x => API.LogWarn(ClassName, + x => PublicApi.Instance.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), @@ -107,7 +102,7 @@ namespace Flow.Launcher.Core.Plugin string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName); if (!File.Exists(configPath)) { - API.LogError(ClassName, $"Didn't find config file <{configPath}>"); + PublicApi.Instance.LogError(ClassName, $"Didn't find config file <{configPath}>"); return null; } @@ -123,19 +118,19 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Invalid json for config <{configPath}>", e); + PublicApi.Instance.LogException(ClassName, $"Invalid json for config <{configPath}>", e); return null; } if (!AllowedLanguage.IsAllowed(metadata.Language)) { - API.LogError(ClassName, $"Invalid language <{metadata.Language}> for config <{configPath}>"); + PublicApi.Instance.LogError(ClassName, $"Invalid language <{metadata.Language}> for config <{configPath}>"); return null; } if (!File.Exists(metadata.ExecuteFilePath)) { - API.LogError(ClassName, $"Execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); + PublicApi.Instance.LogError(ClassName, $"Execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}"); return null; } diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs new file mode 100644 index 000000000..6027b712e --- /dev/null +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -0,0 +1,462 @@ +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(); + + /// + /// 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 (PublicApi.Instance.PluginModified(newPlugin.ID)) + { + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(newPlugin.Name), + Localize.pluginModifiedAlreadyMessage()); + return; + } + + if (PublicApi.Instance.ShowMsgBox( + Localize.InstallPromptSubtitle(newPlugin.Name, newPlugin.Author, Environment.NewLine), + Localize.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( + $"{Localize.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 (!PublicApi.Instance.InstallPlugin(newPlugin, filePath)) + { + return; + } + + if (!newPlugin.IsFromLocalInstallPath) + { + File.Delete(filePath); + } + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, "Failed to install plugin", e); + PublicApi.Instance.ShowMsgError(Localize.ErrorInstallingPlugin()); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + PublicApi.Instance.RestartApp(); + } + else + { + PublicApi.Instance.ShowMsg( + Localize.installbtn(), + Localize.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) + { + PublicApi.Instance.LogException(ClassName, "Failed to validate zip file", e); + PublicApi.Instance.ShowMsgError(Localize.ZipFileNotHavePluginJson()); + return; + } + + if (PublicApi.Instance.PluginModified(plugin.ID)) + { + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(plugin.Name), + Localize.pluginModifiedAlreadyMessage()); + return; + } + + if (Settings.ShowUnknownSourceWarning) + { + if (!InstallSourceKnown(plugin.Website) + && PublicApi.Instance.ShowMsgBox(Localize.InstallFromUnknownSourceSubtitle(Environment.NewLine), + Localize.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 (PublicApi.Instance.PluginModified(oldPlugin.ID)) + { + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(oldPlugin.Name), + Localize.pluginModifiedAlreadyMessage()); + return; + } + + if (PublicApi.Instance.ShowMsgBox( + Localize.UninstallPromptSubtitle(oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + Localize.UninstallPromptTitle(), + button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return; + + var removePluginSettings = PublicApi.Instance.ShowMsgBox( + Localize.KeepPluginSettingsSubtitle(), + Localize.KeepPluginSettingsTitle(), + button: MessageBoxButton.YesNo) == MessageBoxResult.No; + + try + { + if (!await PublicApi.Instance.UninstallPluginAsync(oldPlugin, removePluginSettings)) + { + return; + } + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, "Failed to uninstall plugin", e); + PublicApi.Instance.ShowMsgError(Localize.ErrorUninstallingPlugin()); + return; // don not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + PublicApi.Instance.RestartApp(); + } + else + { + PublicApi.Instance.ShowMsg( + Localize.uninstallbtn(), + Localize.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 (PublicApi.Instance.ShowMsgBox( + Localize.UpdatePromptSubtitle(oldPlugin.Name, oldPlugin.Author, Environment.NewLine), + Localize.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( + $"{Localize.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 PublicApi.Instance.UpdatePluginAsync(oldPlugin, newPlugin, filePath)) + { + return; + } + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, "Failed to update plugin", e); + PublicApi.Instance.ShowMsgError(Localize.ErrorUpdatingPlugin()); + return; // do not restart on failure + } + + if (Settings.AutoRestartAfterChanging) + { + PublicApi.Instance.RestartApp(); + } + else + { + PublicApi.Instance.ShowMsg( + Localize.updatebtn(), + Localize.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 PublicApi.Instance.UpdatePluginManifestAsync(usePrimaryUrlOnly, token); + + // Get all plugins that can be updated + var resultsForUpdate = ( + from existingPlugin in PublicApi.Instance.GetAllPlugins() + join pluginUpdateSource in PublicApi.Instance.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) + && !PublicApi.Instance.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.Count == 0) + { + if (!silentUpdate) + { + PublicApi.Instance.ShowMsg(Localize.updateNoResultTitle(), Localize.updateNoResultSubtitle()); + } + return; + } + + // If all plugins are modified, just return + if (resultsForUpdate.All(x => PublicApi.Instance.PluginModified(x.ID))) + { + return; + } + + // Show message box with button to update all plugins + PublicApi.Instance.ShowMsgWithButton( + Localize.updateAllPluginsTitle(), + Localize.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( + $"{Localize.DownloadingPlugin()} {plugin.PluginNewUserPlugin.Name}", + plugin.PluginNewUserPlugin.UrlDownload, downloadToFilePath, cts); + + // check if user cancelled download before installing plugin + if (cts.IsCancellationRequested) + { + return; + } + + if (!await PublicApi.Instance.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, downloadToFilePath)) + { + return; + } + + anyPluginSuccess = true; + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, "Failed to update plugin", e); + PublicApi.Instance.ShowMsgError(Localize.ErrorUpdatingPlugin()); + } + })); + + if (!anyPluginSuccess) return; + + if (restart) + { + PublicApi.Instance.RestartApp(); + } + else + { + PublicApi.Instance.ShowMsg( + Localize.updatebtn(), + Localize.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 PublicApi.Instance.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 PublicApi.Instance.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 PublicApi.Instance.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false); + } + else + { + await PublicApi.Instance.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 PublicApi.Instance.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 9b525f331..3090212ba 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -6,9 +6,9 @@ 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.DialogJump; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; @@ -18,31 +18,31 @@ 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 readonly string ClassName = nameof(PluginManager); - private static IEnumerable _contextMenuPlugins; - private static IEnumerable _homePlugins; - public static List AllPlugins { get; private set; } public static readonly HashSet GlobalPlugins = new(); public static readonly Dictionary NonGlobalPlugins = new(); - // 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 readonly List _modifiedPlugins = new(); + 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 }; @@ -70,12 +70,12 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e); + PublicApi.Instance.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e); } } - API.SavePluginSettings(); - API.SavePluginCaches(); + PublicApi.Instance.SavePluginSettings(); + PublicApi.Instance.SavePluginCaches(); } public static async ValueTask DisposePluginsAsync() @@ -102,7 +102,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e); + PublicApi.Instance.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e); } } @@ -173,12 +173,36 @@ 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); + 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) @@ -189,7 +213,7 @@ namespace Flow.Launcher.Core.Plugin { if (string.IsNullOrEmpty(metadata.AssemblyName)) { - API.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}"); + PublicApi.Instance.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); @@ -199,7 +223,7 @@ namespace Flow.Launcher.Core.Plugin { if (string.IsNullOrEmpty(metadata.Name)) { - API.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}"); + PublicApi.Instance.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); @@ -220,37 +244,34 @@ namespace Flow.Launcher.Core.Plugin { try { - var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", - () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API))); + var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", + () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, PublicApi.Instance))); pair.Metadata.InitTime += milliseconds; - API.LogInfo(ClassName, + PublicApi.Instance.LogInfo(ClassName, $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); } catch (Exception e) { - API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); + PublicApi.Instance.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"); + PublicApi.Instance.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"); + PublicApi.Instance.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); } } })); await Task.WhenAll(InitTasks); - _contextMenuPlugins = GetPluginsForInterface(); - _homePlugins = GetPluginsForInterface(); - foreach (var plugin in AllPlugins) { // set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin @@ -269,28 +290,36 @@ namespace Flow.Launcher.Core.Plugin } } - if (failedPlugins.Any()) + if (!failedPlugins.IsEmpty) { var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name)); - API.ShowMsg( - API.GetTranslation("failedToInitializePluginsTitle"), - string.Format( - API.GetTranslation("failedToInitializePluginsMessage"), - failed - ), + PublicApi.Instance.ShowMsg( + Localize.failedToInitializePluginsTitle(), + Localize.failedToInitializePluginsMessage(failed), "", false ); } } - public static ICollection ValidPluginsForQuery(Query query) + public static ICollection ValidPluginsForQuery(Query query, bool dialogJump) { if (query is null) return Array.Empty(); if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) - return GlobalPlugins; + { + 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 (PublicApi.Instance.PluginModified(plugin.Metadata.ID)) + return Array.Empty(); return new List { @@ -300,7 +329,7 @@ namespace Flow.Launcher.Core.Plugin public static ICollection ValidPluginsForHomeQuery() { - return _homePlugins.ToList(); + return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); } public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) @@ -310,7 +339,7 @@ namespace Flow.Launcher.Core.Plugin try { - var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); @@ -354,7 +383,7 @@ namespace Flow.Launcher.Core.Plugin try { - var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false)); token.ThrowIfCancellationRequested(); @@ -371,7 +400,37 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e); + PublicApi.Instance.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 PublicApi.Instance.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) + { + PublicApi.Instance.LogException(ClassName, $"Failed to query Dialog Jump for plugin: {metadata.Name}", e); return null; } return results; @@ -402,16 +461,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; @@ -428,7 +497,7 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, + PublicApi.Instance.LogException(ClassName, $"Can't load context menus for plugin <{pluginPair.Metadata.Name}>", e); } @@ -439,7 +508,17 @@ namespace Flow.Launcher.Core.Plugin public static bool IsHomePlugin(string id) { - return _homePlugins.Any(p => p.Metadata.ID == 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) @@ -529,44 +608,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 id) { - return _modifiedPlugins.Contains(id); + return ModifiedPlugins.Contains(id); } - public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) + public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) { - InstallPlugin(newVersion, zipFilePath, checkModified:false); - await UninstallPluginAsync(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false); - _modifiedPlugins.Add(existingVersion.ID); + if (PluginModified(existingVersion.ID)) + { + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(existingVersion.Name), + Localize.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; } - 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); } - public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false) + public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) { - await UninstallPluginAsync(plugin, removePluginFromSettings, removePluginSettings, 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)); + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(plugin.Name), + Localize.pluginModifiedAlreadyMessage()); + return false; } // Unzip plugin files to temp folder @@ -584,12 +681,16 @@ 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"); + PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name), + Localize.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}"); + PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name), + Localize.pluginExistAlreadyMessage()); + return false; } var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; @@ -617,7 +718,7 @@ namespace Flow.Launcher.Core.Plugin var newPluginPath = Path.Combine(installDirectory, folderName); - FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => API.ShowMsgBox(s)); + FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s)); try { @@ -626,20 +727,24 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); + PublicApi.Instance.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e); } if (checkModified) { - _modifiedPlugins.Add(plugin.ID); + ModifiedPlugins.Add(plugin.ID); } + + return true; } - internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) + internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { - throw new ArgumentException($"Plugin {plugin.Name} has been modified"); + PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(plugin.Name), + Localize.pluginModifiedAlreadyMessage()); + return false; } if (removePluginSettings || removePluginFromSettings) @@ -657,7 +762,7 @@ namespace Flow.Launcher.Core.Plugin if (removePluginSettings) { // For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances - if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable) + if (AllowedLanguage.IsDotNet(plugin.Language) && PublicApi.Instance is IRemovable removable) { removable.RemovePluginSettings(plugin.AssemblyName); removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath); @@ -671,9 +776,9 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e); - API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"), - string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name)); + PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e); + PublicApi.Instance.ShowMsgError(Localize.failedToRemovePluginSettingsTitle(), + Localize.failedToRemovePluginSettingsMessage(plugin.Name)); } } @@ -687,12 +792,18 @@ namespace Flow.Launcher.Core.Plugin } catch (Exception e) { - API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e); - API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"), - string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name)); + PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e); + PublicApi.Instance.ShowMsgError(Localize.failedToRemovePluginCacheTitle(), + Localize.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 @@ -700,8 +811,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 256c36065..a8a4fba3a 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -2,9 +2,6 @@ using System.Collections.Generic; 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; @@ -18,10 +15,6 @@ namespace Flow.Launcher.Core.Plugin { 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); @@ -64,7 +57,7 @@ namespace Flow.Launcher.Core.Plugin foreach (var metadata in metadatas) { - var milliseconds = API.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => + var milliseconds = PublicApi.Instance.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => { Assembly assembly = null; IAsyncPlugin plugin = null; @@ -89,19 +82,19 @@ namespace Flow.Launcher.Core.Plugin #else catch (Exception e) when (assembly == null) { - Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); + PublicApi.Instance.LogException(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); } catch (InvalidOperationException e) { - Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); + PublicApi.Instance.LogException(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); } catch (ReflectionTypeLoadException e) { - Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); + PublicApi.Instance.LogException(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); } catch (Exception e) { - Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); + PublicApi.Instance.LogException(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); } #endif @@ -120,17 +113,13 @@ namespace Flow.Launcher.Core.Plugin { 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 ? + Localize.pluginsHaveErrored(): + Localize.pluginHasErrored(); - _ = Task.Run(() => - { - Ioc.Default.GetRequiredService().ShowMsgBox($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + - $"{errorPluginString}{Environment.NewLine}{Environment.NewLine}" + - $"Please refer to the logs for more information", "", - MessageBoxButton.OK, MessageBoxImage.Warning); - }); + PublicApi.Instance.ShowMsgError($"{errorMessage}{Environment.NewLine}{Environment.NewLine}" + + $"{errorPluginString}{Environment.NewLine}{Environment.NewLine}" + + Localize.referToLogs()); } return plugins; 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 b32b09e8f..6f373746e 100644 --- a/Flow.Launcher.Core/Resource/Internationalization.cs +++ b/Flow.Launcher.Core/Resource/Internationalization.cs @@ -1,50 +1,43 @@ 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 Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; -using System.Globalization; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Core.Resource { - public class Internationalization + public class Internationalization : IDisposable { private static readonly string ClassName = nameof(Internationalization); - // We should not initialize API in static constructor because it will create another API instance - private static IPublicAPI api = null; - private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); - private const string Folder = "Languages"; private const string DefaultLanguageCode = "en"; private const string DefaultFile = "en.xaml"; private const string Extension = ".xaml"; private readonly Settings _settings; - private readonly List _languageDirectories = new(); - private readonly List _oldResources = new(); - private readonly string SystemLanguageCode; + private readonly List _languageDirectories = []; + private readonly List _oldResources = []; + private static string SystemLanguageCode; + private readonly SemaphoreSlim _langChangeLock = new(1, 1); public Internationalization(Settings settings) { _settings = settings; - AddFlowLauncherLanguageDirectory(); - SystemLanguageCode = GetSystemLanguageCodeAtStartup(); } - 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(); @@ -65,40 +58,11 @@ namespace Flow.Launcher.Core.Resource string.Equals(languageCode, threeLetterCode, StringComparison.OrdinalIgnoreCase) || string.Equals(languageCode, fullName, StringComparison.OrdinalIgnoreCase)) { - return languageCode; + SystemLanguageCode = languageCode; } } - return DefaultLanguageCode; - } - - private void AddPluginLanguageDirectories() - { - foreach (var plugin in PluginManager.GetPluginsForInterface()) - { - 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 - { - API.LogError(ClassName, $"Can't find plugin path <{location}> for <{plugin.Metadata.Name}>"); - } - } - - LoadDefaultLanguage(); - } - - private void LoadDefaultLanguage() - { - // Removes language files loaded before any plugins were loaded. - // Prevents the language Flow started in from overwriting English if the user switches back to English - RemoveOldLanguageFiles(); - LoadLanguage(AvailableLanguages.English); - _oldResources.Clear(); + SystemLanguageCode = DefaultLanguageCode; } /// @@ -116,13 +80,64 @@ namespace Flow.Launcher.Core.Resource // 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); + 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)) + { + PublicApi.Instance.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() + { + // Removes language files loaded before any plugins were loaded. + // Prevents the language Flow started in from overwriting English if the user switches back to English + RemoveOldLanguageFiles(); + LoadLanguage(AvailableLanguages.English); + _oldResources.Clear(); + } + + #endregion + + #region Change Language + /// /// Change language during runtime. Will change app language and plugin language & save settings. /// @@ -151,11 +166,11 @@ namespace Flow.Launcher.Core.Resource 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) { - API.LogError(ClassName, $"Language code can't be found <{languageCode}>"); + PublicApi.Instance.LogError(ClassName, $"Language code can't be found <{languageCode}>"); return AvailableLanguages.English; } else @@ -164,24 +179,62 @@ namespace Flow.Launcher.Core.Resource } } - private async Task ChangeLanguageAsync(Language language) + private async Task ChangeLanguageAsync(Language language, bool updateMetadata = true) { - // Remove old language files and load language - RemoveOldLanguageFiles(); - if (language != AvailableLanguages.English) - { - LoadLanguage(language); - } + await _langChangeLock.WaitAsync(); + try + { + // 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) + { + PublicApi.Instance.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 for plugins after culture is set - await Task.Run(UpdatePluginMetadataTranslations); + CultureInfo currentCulture; + try + { + 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); @@ -194,14 +247,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 (Ioc.Default.GetRequiredService().ShowMsgBox(text, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) + if (PublicApi.Instance.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; @@ -209,6 +266,7 @@ namespace Flow.Launcher.Core.Resource { dicts.Remove(r); } + _oldResources.Clear(); } private void LoadLanguage(Language language) @@ -237,45 +295,6 @@ 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 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}"; - } - } - - private void UpdatePluginMetadataTranslations() - { - foreach (var p in PluginManager.GetPluginsForInterface()) - { - 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); - } - } - } - private static string LanguageFile(string folder, string language) { if (Directory.Exists(folder)) @@ -287,7 +306,7 @@ namespace Flow.Launcher.Core.Resource } else { - API.LogError(ClassName, $"Language path can't be found <{path}>"); + PublicApi.Instance.LogError(ClassName, $"Language path can't be found <{path}>"); var english = Path.Combine(folder, DefaultFile); if (File.Exists(english)) { @@ -295,7 +314,7 @@ namespace Flow.Launcher.Core.Resource } else { - API.LogError(ClassName, $"Default English Language path can't be found <{path}>"); + PublicApi.Instance.LogError(ClassName, $"Default English Language path can't be found <{path}>"); return string.Empty; } } @@ -305,5 +324,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 + { + PublicApi.Instance.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) + { + PublicApi.Instance.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/LocalizationConverter.cs b/Flow.Launcher.Core/Resource/LocalizationConverter.cs deleted file mode 100644 index fdda33926..000000000 --- a/Flow.Launcher.Core/Resource/LocalizationConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; -using System.Reflection; -using System.Windows.Data; - -namespace Flow.Launcher.Core.Resource -{ - [Obsolete("LocalizationConverter is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] - public class LocalizationConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - 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 deleted file mode 100644 index 3e1a19a76..000000000 --- a/Flow.Launcher.Core/Resource/LocalizedDescriptionAttribute.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; -using CommunityToolkit.Mvvm.DependencyInjection; -using Flow.Launcher.Plugin; - -namespace Flow.Launcher.Core.Resource -{ - public class LocalizedDescriptionAttribute : DescriptionAttribute - { - // 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) - { - _resourceKey = resourceKey; - } - - public override string Description - { - get - { - 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 a6e8dc6bf..c3bb6190f 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -444,17 +444,27 @@ namespace Flow.Launcher.Core.Resource _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)); + _api.ShowMsgBox(Localize.theme_load_failure_path_not_exists(theme)); ChangeTheme(Constant.DefaultTheme); } return false; } - catch (XamlParseException) + catch (XamlParseException e) { - _api.LogError(ClassName, $"Theme <{theme}> fail to parse"); + _api.LogException(ClassName, $"Theme <{theme}> fail to parse xaml", e); if (theme != Constant.DefaultTheme) { - _api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_parse_error"), theme)); + _api.ShowMsgBox(Localize.theme_load_failure_parse_error(theme)); + ChangeTheme(Constant.DefaultTheme); + } + return false; + } + catch (Exception e) + { + _api.LogException(ClassName, $"Theme <{theme}> fail to load", e); + if (theme != Constant.DefaultTheme) + { + _api.ShowMsgBox(Localize.theme_load_failure_parse_error(theme)); ChangeTheme(Constant.DefaultTheme); } return false; diff --git a/Flow.Launcher.Core/Updater.cs b/Flow.Launcher.Core/Updater.cs index 700b1efbc..895a8292d 100644 --- a/Flow.Launcher.Core/Updater.cs +++ b/Flow.Launcher.Core/Updater.cs @@ -49,29 +49,30 @@ namespace Flow.Launcher.Core try { if (!silentUpdate) - _api.ShowMsg(_api.GetTranslation("pleaseWait"), - _api.GetTranslation("update_flowlauncher_update_check")); + _api.ShowMsg(Localize.pleaseWait(), + Localize.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); _api.LogInfo(ClassName, $"Future Release <{Formatted(newUpdateInfo.FutureReleaseEntry)}>"); if (newReleaseVersion <= currentVersion) { if (!silentUpdate) - _api.ShowMsgBox(_api.GetTranslation("update_flowlauncher_already_on_latest")); + _api.ShowMsgBox(Localize.update_flowlauncher_already_on_latest()); return; } if (!silentUpdate) - _api.ShowMsg(_api.GetTranslation("update_flowlauncher_update_found"), - _api.GetTranslation("update_flowlauncher_updating")); + _api.ShowMsg(Localize.update_flowlauncher_update_found(), + Localize.update_flowlauncher_updating()); await updateManager.DownloadReleases(newUpdateInfo.ReleasesToApply).ConfigureAwait(false); @@ -79,12 +80,12 @@ namespace Flow.Launcher.Core if (DataLocation.PortableDataLocationInUse()) { - var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion}\\{DataLocation.PortableFolderName}"; + var targetDestination = updateManager.RootAppDirectory + + $"\\app-{newReleaseVersion}\\{DataLocation.PortableFolderName}"; FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s)); - if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s))) - _api.ShowMsgBox(string.Format(_api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"), - DataLocation.PortableDataPath, - targetDestination)); + if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, + (s) => _api.ShowMsgBox(s))) + _api.ShowMsgBox(Localize.update_flowlauncher_fail_moving_portable_user_profile_data(DataLocation.PortableDataPath, targetDestination)); } else { @@ -95,16 +96,19 @@ namespace Flow.Launcher.Core _api.LogInfo(ClassName, $"Update success:{newVersionTips}"); - if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) + if (_api.ShowMsgBox(newVersionTips, Localize.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) + 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); + _api.LogException(ClassName, + $"Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e); } else { @@ -112,8 +116,8 @@ namespace Flow.Launcher.Core } if (!silentUpdate) - _api.ShowMsg(_api.GetTranslation("update_flowlauncher_fail"), - _api.GetTranslation("update_flowlauncher_check_connection")); + _api.ShowMsgError(Localize.update_flowlauncher_fail(), + Localize.update_flowlauncher_check_connection()); } finally { @@ -124,14 +128,11 @@ 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 @@ -146,10 +147,7 @@ namespace Flow.Launcher.Core 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); @@ -157,19 +155,16 @@ namespace Flow.Launcher.Core return manager; } - private string NewVersionTips(string version) + private static string NewVersionTips(string version) { - var tips = string.Format(_api.GetTranslation("newVersionTips"), version); + var tips = Localize.newVersionTips(version); return tips; } private static string Formatted(T t) { - var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions - { - WriteIndented = true - }); + 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..ec30e484a --- /dev/null +++ b/Flow.Launcher.Core/packages.lock.json @@ -0,0 +1,290 @@ +{ + "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" + } + }, + "Flow.Launcher.Localization": { + "type": "Direct", + "requested": "[0.0.6, )", + "resolved": "0.0.6", + "contentHash": "WNI/TLGPDr3XdOW8gaALN0Uyz9h+bzqOaNZev2nHEuA3HW9o7XuqaM6C0PqNi96mNgxiypwWpVazBNzaylJ2Aw==" + }, + "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==" + }, + "ini-parser": { + "type": "Transitive", + "resolved": "2.5.2", + "contentHash": "hp3gKmC/14+6eKLgv7Jd1Z7OV86lO+tNfOXr/stQbwmRhdQuXVSvrRAuAe7G5+lwhkov0XkqZ8/bn1PYWMx6eg==" + }, + "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.Localization": "[0.0.6, )", + "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, )", + "ini-parser": "[2.5.2, )" + } + }, + "flow.launcher.plugin": { + "type": "Project", + "dependencies": { + "JetBrains.Annotations": "[2025.2.2, )" + } + } + } + } +} \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs new file mode 100644 index 000000000..9035a541d --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs @@ -0,0 +1,1072 @@ +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(); + + private static HWND _mainWindowHandle = HWND.Null; + + private static readonly Dictionary _dialogJumpExplorers = new(); + + private static DialogJumpExplorerPair _lastExplorer = null; + private static readonly Lock _lastExplorerLock = new(); + + private static readonly Dictionary _dialogJumpDialogs = new(); + + private static IDialogJumpDialogWindow _dialogWindow = null; + private static readonly Lock _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 = []; + private static readonly Lock _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 (PublicApi.Instance.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 (PublicApi.Instance.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 (PublicApi.Instance.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 (PublicApi.Instance.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 (PublicApi.Instance.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 + _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 1085cc833..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,74 +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; - } - - /// - /// 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 31547200b..4cde3f6e0 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 @@ -33,6 +34,7 @@ prompt 4 false + $(NoWarn);FLSG0007 @@ -53,28 +55,43 @@ - + - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + all - + + + - - - + + + + + true + + + + + + Languages\en.xaml + - \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/Http/Http.cs b/Flow.Launcher.Infrastructure/Http/Http.cs index 22eb065f5..f8c111f36 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -4,10 +4,8 @@ using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; -using Flow.Launcher.Plugin; using JetBrains.Annotations; namespace Flow.Launcher.Infrastructure.Http @@ -78,7 +76,7 @@ namespace Flow.Launcher.Infrastructure.Http } catch (UriFormatException e) { - Ioc.Default.GetRequiredService().ShowMsg("Please try again", "Unable to parse Http Proxy"); + PublicApi.Instance.ShowMsgError(Localize.pleaseTryAgain(), Localize.parseProxyFailed()); Log.Exception(ClassName, "Unable to parse Uri", e); } } 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/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 86df01a30..598347fd2 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -19,37 +19,42 @@ namespace Flow.Launcher.Infrastructure.Image 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(); - _storage.ClearData(); - - 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 () => { @@ -64,42 +69,26 @@ namespace Flow.Launcher.Infrastructure.Image }); } - public static async Task SaveAsync() + public static void Save() { - await storageLock.WaitAsync(); - - try + lock (storageLock) { - await _storage.SaveAsync(ImageCache.EnumerateEntries() - .Select(x => x.Key) - .ToList()); - } - catch (System.Exception e) - { - Log.Exception(ClassName, "Failed to save image cache to file", e); - } - 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); + } } } - public static async Task WaitSaveAsync() + private static List<(string, bool)> LoadStorageToConcurrentDictionary() { - await storageLock.WaitAsync(); - storageLock.Release(); - } - - private static async Task> LoadStorageToConcurrentDictionaryAsync() - { - await storageLock.WaitAsync(); - try + lock (storageLock) { - return await _storage.TryLoadAsync(new List<(string, bool)>()); - } - finally - { - storageLock.Release(); + return _storage.TryLoad([]); } } @@ -174,7 +163,7 @@ namespace Flow.Launcher.Infrastructure.Image 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); } @@ -273,7 +262,7 @@ namespace Flow.Launcher.Infrastructure.Image } else { - image = ImageCache[Constant.MissingImgIcon, false]; + image = MissingImage; path = Constant.MissingImgIcon; } @@ -338,7 +327,7 @@ namespace Flow.Launcher.Infrastructure.Image return img; } - private static ImageSource LoadFullImage(string path) + private static BitmapImage LoadFullImage(string path) { BitmapImage image = new BitmapImage(); image.BeginInit(); @@ -375,7 +364,7 @@ namespace Flow.Launcher.Infrastructure.Image return image; } - private static ImageSource LoadSvgImage(string path, bool loadFullImage = false) + private static RenderTargetBitmap LoadSvgImage(string path, bool loadFullImage = false) { // Set up drawing settings var desiredHeight = loadFullImage ? FullImageSize : SmallIconSize; diff --git a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs index 4ce0df026..86f757eb8 100644 --- a/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs +++ b/Flow.Launcher.Infrastructure/Image/ThumbnailReader.cs @@ -1,13 +1,14 @@ using System; -using System.Runtime.InteropServices; using System.IO; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using System.Windows.Media.Imaging; +using IniParser; using Windows.Win32; using Windows.Win32.Foundation; -using Windows.Win32.UI.Shell; using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.Shell; namespace Flow.Launcher.Infrastructure.Image { @@ -35,9 +36,32 @@ namespace Flow.Launcher.Infrastructure.Image private static readonly HRESULT S_PATHNOTFOUND = (HRESULT)0x8004B205; + private const string UrlExtension = ".url"; + + /// + /// Obtains a BitmapSource thumbnail for the specified file. + /// + /// + /// If the file is a Windows URL shortcut (".url"), the method attempts to resolve the shortcut's icon and use that for the thumbnail; otherwise it requests a thumbnail for the file path. The native HBITMAP used to create the BitmapSource is always released to avoid native memory leaks. + /// + /// Path to the file (can be a regular file or a ".url" shortcut). + /// Requested thumbnail width in pixels. + /// Requested thumbnail height in pixels. + /// Thumbnail extraction options (flags) controlling fallback and caching behavior. + /// A BitmapSource representing the requested thumbnail. public static BitmapSource GetThumbnail(string fileName, int width, int height, ThumbnailOptions options) { - HBITMAP hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); + HBITMAP hBitmap; + + var extension = Path.GetExtension(fileName); + if (string.Equals(extension, UrlExtension, StringComparison.OrdinalIgnoreCase)) + { + hBitmap = GetHBitmapForUrlFile(fileName, width, height, options); + } + else + { + hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); + } try { @@ -50,6 +74,21 @@ namespace Flow.Launcher.Infrastructure.Image } } + /// + /// Obtains a native HBITMAP for the specified file at the requested size using the Windows Shell image factory. + /// + /// + /// If is and thumbnail extraction fails + /// due to extraction errors or a missing path, the method falls back to requesting an icon (). + /// The returned HBITMAP is a raw GDI handle; the caller is responsible for releasing it (e.g., via DeleteObject) to avoid native memory leaks. + /// + /// Path to the file to thumbnail. + /// Requested thumbnail width in pixels. + /// Requested thumbnail height in pixels. + /// Thumbnail request flags that control behavior (e.g., ThumbnailOnly, IconOnly). + /// An HBITMAP handle containing the image. Caller must free the handle when finished. + /// If creating the shell item fails (HRESULT returned by SHCreateItemFromParsingName). + /// If the shell item does not expose IShellItemImageFactory or if an unexpected error occurs while obtaining the image. private static unsafe HBITMAP GetHBitmap(string fileName, int width, int height, ThumbnailOptions options) { var retCode = PInvoke.SHCreateItemFromParsingName( @@ -108,5 +147,44 @@ namespace Flow.Launcher.Infrastructure.Image return hBitmap; } + + /// + /// Obtains an HBITMAP for a Windows .url shortcut by resolving its IconFile entry and delegating to GetHBitmap. + /// + /// + /// The method parses the .url file as an INI, looks in the "InternetShortcut" section for the "IconFile" entry, + /// and requests a bitmap for that icon path. If no IconFile is present or any error occurs while reading or + /// resolving the icon, it falls back to requesting a thumbnail for the .url file itself. + /// + /// Path to the .url shortcut file. + /// Requested thumbnail width (pixels). + /// Requested thumbnail height (pixels). + /// ThumbnailOptions flags controlling extraction behavior. + /// An HBITMAP containing the requested image; callers are responsible for freeing the native handle. + private static unsafe HBITMAP GetHBitmapForUrlFile(string fileName, int width, int height, ThumbnailOptions options) + { + HBITMAP hBitmap; + + try + { + var parser = new FileIniDataParser(); + var data = parser.ReadFile(fileName); + var urlSection = data["InternetShortcut"]; + + var iconPath = urlSection?["IconFile"]; + if (!File.Exists(iconPath)) + { + // If the IconFile is missing, throw exception to fallback to the default icon + throw new FileNotFoundException("Icon file not specified in Internet shortcut (.url) file."); + } + hBitmap = GetHBitmap(Path.GetFullPath(iconPath), width, height, options); + } + catch + { + hBitmap = GetHBitmap(Path.GetFullPath(fileName), width, height, options); + } + + return hBitmap; + } } } diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index 09eb98f46..2a5b826a9 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -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 }; @@ -65,26 +65,22 @@ namespace Flow.Launcher.Infrastructure.Logger public static void SetLogLevel(LOGLEVEL level) { - switch (level) + var rule = LogManager.Configuration.FindRuleByName("file"); + + var nlogLevel = level switch { - case LOGLEVEL.DEBUG: - UseDebugLogLevel(); - break; - default: - UseInfoLogLevel(); - break; - } - Info(nameof(Logger), $"Using log level: {level}."); - } + LOGLEVEL.NONE => LogLevel.Off, + LOGLEVEL.ERROR => LogLevel.Error, + LOGLEVEL.DEBUG => LogLevel.Debug, + _ => LogLevel.Info + }; - private static void UseDebugLogLevel() - { - LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Debug, LogLevel.Fatal); - } + rule.SetLoggingLevels(nlogLevel, LogLevel.Fatal); - private static void UseInfoLogLevel() - { - LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Info, 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) @@ -169,7 +165,9 @@ namespace Flow.Launcher.Infrastructure.Logger public enum LOGLEVEL { - DEBUG, - INFO + NONE, + ERROR, + INFO, + DEBUG } } diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index edc71feef..cd072f635 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -34,12 +34,6 @@ WINDOW_STYLE SetLastError WINDOW_EX_STYLE -GetSystemMetrics -EnumDisplayMonitors -MonitorFromWindow -GetMonitorInfo -MONITORINFOEXW - WM_ENTERSIZEMOVE WM_EXITSIZEMOVE WM_NCLBUTTONDBLCLK @@ -66,3 +60,35 @@ 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 + +DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS +WM_POWERBROADCAST +PBT_APMRESUMEAUTOMATIC +PBT_APMRESUMESUSPEND +PowerRegisterSuspendResumeNotification +PowerUnregisterSuspendResumeNotification +DeviceNotifyCallbackRoutine \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs index 8eaa757be..1c0cc6872 100644 --- a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs +++ b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs @@ -1,209 +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 CommunityToolkit.Mvvm.DependencyInjection; +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 Settings _settings; + private readonly ConcurrentDictionary _pinyinCache = new(); + private readonly Settings _settings; + private ReadOnlyDictionary currentDoublePinyinTable; public PinyinAlphabet() { - Initialize(Ioc.Default.GetRequiredService()); + _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; + } + }; } - private void Initialize([NotNull] Settings settings) + public void Reload() { - _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + LoadDoublePinyinTable(); + _pinyinCache.Clear(); } - public bool CanBeTranslated(string stringToTranslate) + private void CreateDoublePinyinTableFromStream(Stream jsonStream) { - return WordsHelper.HasChinese(stringToTranslate); + 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/Storage/BinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs index 48e6b5523..15200f5aa 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -12,7 +12,7 @@ using MemoryPack; namespace Flow.Launcher.Infrastructure.Storage { /// - /// Stroage object using binary data + /// Storage object using binary data /// Normally, it has better performance, but not readable /// /// @@ -53,6 +53,45 @@ namespace Flow.Launcher.Infrastructure.Storage 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(ClassName, $"Zero length cache file <{FilePath}>"); + Data = defaultData; + Save(); + } + + var bytes = File.ReadAllBytes(FilePath); + Data = Deserialize(bytes, defaultData); + } + else + { + 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; @@ -79,26 +118,31 @@ namespace Flow.Launcher.Infrastructure.Storage return Data; } - private static async ValueTask DeserializeAsync(Stream stream, T defaultData) + private async ValueTask DeserializeAsync(Stream stream, T defaultData) { try { var t = await MemoryPackSerializer.DeserializeAsync(stream); return t ?? defaultData; } - catch (System.Exception) + 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); + var serialized = MemoryPackSerializer.Serialize(data); File.WriteAllBytes(FilePath, serialized); } @@ -107,15 +151,6 @@ namespace Flow.Launcher.Infrastructure.Storage await SaveAsync(Data.NonNull()); } - // ImageCache need to convert data into concurrent dictionary for usage, - // so we would better to clear the data - public void ClearData() - { - Data = default; - } - - // ImageCache storages data in its class, - // so we need to pass it to SaveAsync public async ValueTask SaveAsync(T data) { // User may delete the directory, so we need to check it @@ -124,5 +159,12 @@ namespace Flow.Launcher.Infrastructure.Storage 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/StringMatcher.cs b/Flow.Launcher.Infrastructure/StringMatcher.cs index e85c5d6f4..2882cb8f0 100644 --- a/Flow.Launcher.Infrastructure/StringMatcher.cs +++ b/Flow.Launcher.Infrastructure/StringMatcher.cs @@ -68,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. @@ -228,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; @@ -237,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; 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 f9504e6d9..000000000 --- a/Flow.Launcher.Infrastructure/UI/EnumBindingSource.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Windows.Markup; - -namespace Flow.Launcher.Infrastructure.UI -{ - [Obsolete("EnumBindingSourceExtension is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")] - public class EnumBindingSourceExtension : MarkupExtension - { - private Type _enumType; - 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..009b27666 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomBrowserViewModel.cs @@ -1,11 +1,13 @@ +using System.Text.Json.Serialization; using Flow.Launcher.Plugin; -using System.Text.Json.Serialization; namespace Flow.Launcher.Infrastructure.UserSettings { public class CustomBrowserViewModel : BaseModel { public string Name { get; set; } + [JsonIgnore] + public string DisplayName => Name == "Default" ? Localize.defaultBrowser_default() : Name; public string Path { get; set; } public string PrivateArg { get; set; } public bool EnablePrivate { get; set; } @@ -26,8 +28,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..ae406f4c5 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomExplorerViewModel.cs @@ -1,10 +1,13 @@ -using Flow.Launcher.Plugin; +using System.Text.Json.Serialization; +using Flow.Launcher.Plugin; -namespace Flow.Launcher.ViewModel +namespace Flow.Launcher.Infrastructure.UserSettings { public class CustomExplorerViewModel : BaseModel { public string Name { get; set; } + [JsonIgnore] + public string DisplayName => Name == "Explorer" ? Localize.fileManagerExplorer() : Name; public string Path { get; set; } public string FileArgument { get; set; } = "\"%d\""; public string DirectoryArgument { get; set; } = "\"%d\""; @@ -21,5 +24,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 2603d4675..a2e95b668 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,8 +1,6 @@ using System; using System.Text.Json.Serialization; using System.Threading.Tasks; -using CommunityToolkit.Mvvm.DependencyInjection; -using Flow.Launcher.Plugin; namespace Flow.Launcher.Infrastructure.UserSettings { @@ -55,11 +53,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings { public string Description { get; set; } - 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 string LocalizedDescription => PublicApi.Instance.GetTranslation(Description); public BaseBuiltinShortcutModel(string key, string description) { diff --git a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs index 5b948e450..de9cb841e 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/DataLocation.cs @@ -7,8 +7,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings { public const string PortableFolderName = "UserData"; public const string DeletionIndicatorFile = ".dead"; - public static string PortableDataPath = Path.Combine(Constant.ProgramDirectory, PortableFolderName); - public static string RoamingDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FlowLauncher"); + public static readonly string PortableDataPath = Path.Combine(Constant.ProgramDirectory, PortableFolderName); + public static readonly string RoamingDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FlowLauncher"); public static string DataDirectory() { if (PortableDataLocationInUse()) @@ -19,7 +19,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings public static bool PortableDataLocationInUse() { - if (Directory.Exists(PortableDataPath) && !File.Exists(DeletionIndicatorFile)) + if (Directory.Exists(PortableDataPath) && + !File.Exists(Path.Combine(PortableDataPath, DeletionIndicatorFile))) return true; return false; 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/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 887da29f5..370662adb 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -9,7 +9,6 @@ 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 { @@ -25,7 +24,13 @@ namespace Flow.Launcher.Infrastructure.UserSettings 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() @@ -34,9 +39,37 @@ namespace Flow.Launcher.Infrastructure.UserSettings } public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {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"; @@ -52,6 +85,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings 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 @@ -59,8 +93,11 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _language; set { - _language = value; - OnPropertyChanged(); + if (_language != value) + { + _language = value; + OnPropertyChanged(); + } } } private string _theme = Constant.DefaultTheme; @@ -69,7 +106,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _theme; set { - if (value != _theme) + if (_theme != value) { _theme = value; OnPropertyChanged(); @@ -78,7 +115,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } public bool UseDropShadowEffect { get; set; } = true; - public BackdropTypes BackdropType{ get; set; } = BackdropTypes.None; + public BackdropTypes BackdropType { get; set; } = BackdropTypes.None; public string ReleaseNotesVersion { get; set; } = string.Empty; /* Appearance Settings. It should be separated from the setting later.*/ @@ -116,8 +153,11 @@ namespace Flow.Launcher.Infrastructure.UserSettings { _settingWindowFont = value; OnPropertyChanged(); - Application.Current.Resources["SettingWindowFont"] = new FontFamily(value); - Application.Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(value); + if (Application.Current != null) + { + Application.Current.Resources["SettingWindowFont"] = new FontFamily(value); + Application.Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(value); + } } } } @@ -190,9 +230,13 @@ namespace Flow.Launcher.Infrastructure.UserSettings } } } - + 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; [JsonIgnore] @@ -281,13 +325,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; @@ -300,9 +401,12 @@ namespace Flow.Launcher.Infrastructure.UserSettings get => _querySearchPrecision; set { - _querySearchPrecision = value; - if (_stringMatcher != null) - _stringMatcher.UserSettingSearchPrecision = value; + if (_querySearchPrecision != value) + { + _querySearchPrecision = value; + if (_stringMatcher != null) + _stringMatcher.UserSettingSearchPrecision = value; + } } } @@ -369,13 +473,30 @@ namespace Flow.Launcher.Infrastructure.UserSettings 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; @@ -411,7 +532,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings { var list = FixedHotkeys(); - // Customizeable hotkeys + // Customizable hotkeys if (!string.IsNullOrEmpty(Hotkey)) list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = "")); if (!string.IsNullOrEmpty(PreviewHotkey)) @@ -431,7 +552,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings if (!string.IsNullOrEmpty(SettingWindowHotkey)) list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = "")); if (!string.IsNullOrEmpty(OpenHistoryHotkey)) - list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = "")); + list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = "")); if (!string.IsNullOrEmpty(OpenContextMenuHotkey)) list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = "")); if (!string.IsNullOrEmpty(SelectNextPageHotkey)) @@ -442,6 +563,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) @@ -537,9 +660,41 @@ namespace Flow.Launcher.Infrastructure.UserSettings public enum BackdropTypes { - None, + 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 86e7b7c97..8a41e12b4 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -13,10 +13,14 @@ 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.Power; +using Windows.Win32.System.Threading; using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.Shell.Common; using Windows.Win32.UI.WindowsAndMessaging; @@ -118,9 +122,9 @@ namespace Flow.Launcher.Infrastructure #region Window Foreground - public static nint GetForegroundWindow() + public static unsafe nint GetForegroundWindow() { - return PInvoke.GetForegroundWindow().Value; + return (nint)PInvoke.GetForegroundWindow().Value; } public static bool SetForegroundWindow(Window window) @@ -138,6 +142,11 @@ namespace Flow.Launcher.Infrastructure 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()); @@ -279,15 +288,15 @@ namespace Flow.Launcher.Infrastructure { var hWndDesktop = PInvoke.FindWindowEx(hWnd, HWND.Null, "SHELLDLL_DefView", null); hWndDesktop = PInvoke.FindWindowEx(hWndDesktop, HWND.Null, "SysListView32", "FolderView"); - if (hWndDesktop.Value != IntPtr.Zero) + if (hWndDesktop != HWND.Null) { return false; } } var monitorInfo = MonitorInfo.GetNearestDisplayMonitor(hWnd); - return (appBounds.bottom - appBounds.top) == monitorInfo.RectMonitor.Height && - (appBounds.right - appBounds.left) == monitorInfo.RectMonitor.Width; + return (appBounds.bottom - appBounds.top) == monitorInfo.Bounds.Height && + (appBounds.right - appBounds.left) == monitorInfo.Bounds.Width; } #endregion @@ -344,6 +353,16 @@ namespace Flow.Launcher.Infrastructure 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 @@ -480,7 +499,7 @@ namespace Flow.Launcher.Infrastructure /// Restores the previously backed-up keyboard layout. /// If it wasn't backed up or has already been restored, this method does nothing. /// - public static void RestorePreviousKeyboardLayout() + public unsafe static void RestorePreviousKeyboardLayout() { if (_previousLayout == HKL.Null) return; @@ -491,7 +510,7 @@ namespace Flow.Launcher.Infrastructure hwnd, PInvoke.WM_INPUTLANGCHANGEREQUEST, PInvoke.INPUTLANGCHANGE_FORWARD, - _previousLayout.Value + new LPARAM((nint)_previousLayout.Value) ); _previousLayout = HKL.Null; @@ -761,6 +780,62 @@ namespace Flow.Launcher.Infrastructure #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 @@ -791,5 +866,155 @@ namespace Flow.Launcher.Infrastructure } #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 + + #region Sleep Mode Listener + + private static Action _func; + private static PDEVICE_NOTIFY_CALLBACK_ROUTINE _callback = null; + private static DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS _recipient; + private static SafeHandle _recipientHandle; + private static HPOWERNOTIFY _handle = HPOWERNOTIFY.Null; + + /// + /// Registers a listener for sleep mode events. + /// Inspired from: https://github.com/XKaguya/LenovoLegionToolkit + /// https://blog.csdn.net/mochounv/article/details/114668594 + /// + /// + /// + public static unsafe void RegisterSleepModeListener(Action func) + { + if (_callback != null) + { + // Only register if not already registered + return; + } + + _func = func; + _callback = new PDEVICE_NOTIFY_CALLBACK_ROUTINE(DeviceNotifyCallback); + _recipient = new DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS() + { + Callback = _callback, + Context = null + }; + + _recipientHandle = new StructSafeHandle(_recipient); + _handle = PInvoke.PowerRegisterSuspendResumeNotification( + REGISTER_NOTIFICATION_FLAGS.DEVICE_NOTIFY_CALLBACK, + _recipientHandle, + out var handle) == WIN32_ERROR.ERROR_SUCCESS ? + new HPOWERNOTIFY(new IntPtr(handle)) : + HPOWERNOTIFY.Null; + if (_handle.IsNull) + { + throw new Win32Exception("Error registering for power notifications: " + Marshal.GetLastWin32Error()); + } + } + + /// + /// Unregisters the sleep mode listener. + /// + public static void UnregisterSleepModeListener() + { + if (!_handle.IsNull) + { + PInvoke.PowerUnregisterSuspendResumeNotification(_handle); + _handle = HPOWERNOTIFY.Null; + _func = null; + _callback = null; + _recipientHandle = null; + } + } + + private static unsafe uint DeviceNotifyCallback(void* context, uint type, void* setting) + { + switch (type) + { + case PInvoke.PBT_APMRESUMEAUTOMATIC: + // Operation is resuming automatically from a low-power state.This message is sent every time the system resumes + _func?.Invoke(); + break; + + case PInvoke.PBT_APMRESUMESUSPEND: + // Operation is resuming from a low-power state.This message is sent after PBT_APMRESUMEAUTOMATIC if the resume is triggered by user input, such as pressing a key + _func?.Invoke(); + break; + } + + return 0; + } + + private sealed class StructSafeHandle : SafeHandle where T : struct + { + private readonly nint _ptr = nint.Zero; + + public StructSafeHandle(T recipient) : base(nint.Zero, true) + { + var pRecipient = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(recipient, pRecipient, false); + SetHandle(pRecipient); + _ptr = pRecipient; + } + + public override bool IsInvalid => handle == nint.Zero; + + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(_ptr); + return true; + } + } + + #endregion } } diff --git a/Flow.Launcher.Infrastructure/packages.lock.json b/Flow.Launcher.Infrastructure/packages.lock.json new file mode 100644 index 000000000..db77f9d93 --- /dev/null +++ b/Flow.Launcher.Infrastructure/packages.lock.json @@ -0,0 +1,210 @@ +{ + "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==" + }, + "Flow.Launcher.Localization": { + "type": "Direct", + "requested": "[0.0.6, )", + "resolved": "0.0.6", + "contentHash": "WNI/TLGPDr3XdOW8gaALN0Uyz9h+bzqOaNZev2nHEuA3HW9o7XuqaM6C0PqNi96mNgxiypwWpVazBNzaylJ2Aw==" + }, + "Fody": { + "type": "Direct", + "requested": "[6.9.3, )", + "resolved": "6.9.3", + "contentHash": "1CUGgFdyECDKgi5HaUBhdv6k+VG9Iy4OCforGfHyar3xQXAJypZkzymgKtWj/4SPd6nSG0Qi7NH71qHrDSZLaA==" + }, + "ini-parser": { + "type": "Direct", + "requested": "[2.5.2, )", + "resolved": "2.5.2", + "contentHash": "hp3gKmC/14+6eKLgv7Jd1Z7OV86lO+tNfOXr/stQbwmRhdQuXVSvrRAuAe7G5+lwhkov0XkqZ8/bn1PYWMx6eg==" + }, + "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/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 4a26cec95..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.5.0 - 4.5.0 - 4.5.0 - 4.5.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,17 @@ - + 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/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/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 76c7a4911..dd44647b4 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; @@ -23,8 +23,8 @@ namespace Flow.Launcher.Plugin /// /// 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); @@ -49,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); @@ -65,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. @@ -97,7 +97,7 @@ namespace Flow.Launcher.Plugin /// Show the MainWindow when hiding /// void ShowMainWindow(); - + /// /// Focus the query text box in the main window /// @@ -115,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; @@ -171,7 +171,7 @@ namespace Flow.Launcher.Plugin string GetTranslation(string key); /// - /// Get all loaded plugins + /// Get all loaded plugins /// /// List GetAllPlugins(); @@ -229,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 @@ -237,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 @@ -305,8 +305,8 @@ namespace Flow.Launcher.Plugin 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 + /// 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 = ""); @@ -336,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. /// @@ -378,7 +393,7 @@ namespace Flow.Launcher.Plugin /// /// Reloads the query. - /// When current results are from context menu or history, it will go back to query results before requerying. + /// 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); @@ -532,8 +547,10 @@ namespace Flow.Launcher.Plugin /// /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// - /// - public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); + /// + /// 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, @@ -543,7 +560,10 @@ namespace Flow.Launcher.Plugin /// /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// - public void InstallPlugin(UserPlugin plugin, string zipFilePath); + /// + /// True if the plugin is installed successfully, false otherwise. + /// + public bool InstallPlugin(UserPlugin plugin, string zipFilePath); /// /// Uninstall a plugin @@ -552,8 +572,10 @@ namespace Flow.Launcher.Plugin /// /// Plugin has their own settings. If this is set to true, the plugin settings will be removed. /// - /// - public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); + /// + /// 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 @@ -580,5 +602,28 @@ namespace Flow.Launcher.Plugin /// /// 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/NativeMethods.txt b/Flow.Launcher.Plugin/NativeMethods.txt index 0596691cc..901695a8b 100644 --- a/Flow.Launcher.Plugin/NativeMethods.txt +++ b/Flow.Launcher.Plugin/NativeMethods.txt @@ -5,4 +5,12 @@ GetWindowTextLength WM_KEYDOWN WM_KEYUP WM_SYSKEYDOWN -WM_SYSKEYUP \ No newline at end of file +WM_SYSKEYUP + +GetSystemMetrics +EnumDisplayMonitors +MonitorFromWindow +GetMonitorInfo +MONITORINFOEXW +GetCursorPos +MonitorFromPoint \ No newline at end of file diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index f0fcd48ff..2c9b8d4fd 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -57,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; } /// @@ -257,6 +260,17 @@ namespace Flow.Launcher.Plugin /// 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 /// @@ -308,6 +322,7 @@ namespace Flow.Launcher.Plugin AddSelectedCount = AddSelectedCount, RecordKey = RecordKey, ShowBadge = ShowBadge, + QuerySuggestionText = QuerySuggestionText }; } diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index 6c506cfc0..3af57f00d 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -150,6 +150,16 @@ namespace Flow.Launcher.Plugin.SharedCommands return File.Exists(filePath); } + /// + /// Checks if a file or directory exists + /// + /// + /// + public static bool FileOrLocationExists(this string path) + { + return LocationExists(path) || FileExists(path); + } + /// /// Open a directory window (using the OS's default handler, usually explorer) /// diff --git a/Flow.Launcher.Infrastructure/MonitorInfo.cs b/Flow.Launcher.Plugin/SharedModels/MonitorInfo.cs similarity index 52% rename from Flow.Launcher.Infrastructure/MonitorInfo.cs rename to Flow.Launcher.Plugin/SharedModels/MonitorInfo.cs index 3221708c1..6808c9c91 100644 --- a/Flow.Launcher.Infrastructure/MonitorInfo.cs +++ b/Flow.Launcher.Plugin/SharedModels/MonitorInfo.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System; using System.Runtime.InteropServices; using System.Windows; using Windows.Win32; @@ -7,13 +6,16 @@ using Windows.Win32.Foundation; using Windows.Win32.Graphics.Gdi; using Windows.Win32.UI.WindowsAndMessaging; -namespace Flow.Launcher.Infrastructure; +namespace Flow.Launcher.Plugin.SharedModels; /// /// Contains full information about a display monitor. -/// Codes are edited from: . +/// Inspired from: https://github.com/Jack251970/DesktopWidgets3. /// -internal class MonitorInfo +/// +/// 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). @@ -23,14 +25,14 @@ internal class MonitorInfo { var monitorCount = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CMONITORS); var list = new List(monitorCount); - var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + 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, (RECT?)null, callback, dwData); + bool ok = PInvoke.EnumDisplayMonitors(hdc, null, callback, dwData); if (!ok) { Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); @@ -43,11 +45,11 @@ internal class MonitorInfo /// /// Window handle /// The display monitor that is nearest to a given window, or null if no monitor is found. - public static unsafe MonitorInfo GetNearestDisplayMonitor(HWND hwnd) + public static unsafe MonitorInfo GetNearestDisplayMonitor(nint hwnd) { - var nearestMonitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + var nearestMonitor = PInvoke.MonitorFromWindow(new(hwnd), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); MonitorInfo nearestMonitorInfo = null; - var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) => + var callback = new MONITORENUMPROC((monitor, deviceContext, rect, data) => { if (monitor == nearestMonitor) { @@ -58,7 +60,7 @@ internal class MonitorInfo }); var dwData = new LPARAM(); var hdc = new HDC(); - bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData); + bool ok = PInvoke.EnumDisplayMonitors(hdc, null, callback, dwData); if (!ok) { Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); @@ -66,17 +68,75 @@ internal class MonitorInfo 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) { - RectMonitor = + 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); - RectWork = + 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(); @@ -93,7 +153,7 @@ internal class MonitorInfo /// /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. /// - public Rect RectMonitor { get; } + public Rect Bounds { get; } /// /// Gets the work area rectangle of the display monitor, expressed in virtual-screen coordinates. @@ -101,15 +161,15 @@ internal class MonitorInfo /// /// If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values. /// - public Rect RectWork { get; } + public Rect WorkingArea { get; } /// - /// Gets if the monitor is the the primary display monitor. + /// Gets if the monitor is the primary display monitor. /// - public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(IntPtr.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); + public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(nint.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY); /// - public override string ToString() => $"{Name} {RectMonitor.Width}x{RectMonitor.Height}"; + public override string ToString() => $"{Name} {Bounds.Width}x{Bounds.Height}"; private static unsafe bool GetMonitorInfo(HMONITOR hMonitor, ref MONITORINFOEXW lpmi) { diff --git a/Flow.Launcher.Plugin/UserPlugin.cs b/Flow.Launcher.Plugin/UserPlugin.cs index 74a16b83d..4257a8b2e 100644 --- a/Flow.Launcher.Plugin/UserPlugin.cs +++ b/Flow.Launcher.Plugin/UserPlugin.cs @@ -76,5 +76,10 @@ namespace Flow.Launcher.Plugin /// 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/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index 0241a374e..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/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/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 887b13126..5af76f37f 100644 --- a/Flow.Launcher/ActionKeywords.xaml +++ b/Flow.Launcher/ActionKeywords.xaml @@ -14,102 +14,102 @@ + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -117,15 +117,15 @@ [Conditional("RELEASE")] - private void AutoStartup() + private static void AutoStartup() { // we try to enable auto-startup on first launch, or reenable if it was removed // but the user still has the setting set @@ -249,13 +284,13 @@ namespace Flow.Launcher // but if it fails (permissions, etc) then don't keep retrying // this also gives the user a visual indication in the Settings widget _settings.StartFlowLauncherOnSystemStartup = false; - API.ShowMsg(API.GetTranslation("setAutoStartFailed"), e.Message); + API.ShowMsgError(Localize.setAutoStartFailed(), e.Message); } } } [Conditional("RELEASE")] - private void AutoUpdates() + private static void AutoUpdates() { _ = Task.Run(async () => { @@ -272,6 +307,38 @@ namespace Flow.Launcher }); } + [Conditional("RELEASE")] + private static void AutoPluginUpdates() + { + _ = Task.Run(async () => + { + if (_settings.AutoUpdatePlugins) + { + // check plugin updates every 5 hour + var timer = new PeriodicTimer(TimeSpan.FromHours(5)); + await PluginInstaller.CheckForPluginUpdatesAsync((plugins) => + { + Current.Dispatcher.Invoke(() => + { + var pluginUpdateWindow = new PluginUpdateWindow(plugins); + pluginUpdateWindow.ShowDialog(); + }); + }); + + while (await timer.WaitForNextTickAsync()) + // check updates on startup + await PluginInstaller.CheckForPluginUpdatesAsync((plugins) => + { + Current.Dispatcher.Invoke(() => + { + var pluginUpdateWindow = new PluginUpdateWindow(plugins); + pluginUpdateWindow.ShowDialog(); + }); + }); + } + }); + } + #endregion #region Register Events @@ -363,6 +430,8 @@ namespace Flow.Launcher // since some resources owned by the thread need to be disposed. _mainWindow?.Dispatcher.Invoke(_mainWindow.Dispose); _mainVM?.Dispose(); + DialogJump.Dispose(); + _internationalization.Dispose(); } API.LogInfo(ClassName, "End Flow Launcher dispose ----------------------------------------------------"); diff --git a/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs b/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs index 41e879913..82da6d936 100644 --- a/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs +++ b/Flow.Launcher/Converters/BoolToIMEConversionModeConverter.cs @@ -5,7 +5,7 @@ using System.Windows.Input; namespace Flow.Launcher.Converters; -internal class BoolToIMEConversionModeConverter : IValueConverter +public class BoolToIMEConversionModeConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { @@ -22,7 +22,7 @@ internal class BoolToIMEConversionModeConverter : IValueConverter } } -internal class BoolToIMEStateConverter : IValueConverter +public class BoolToIMEStateConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { diff --git a/Flow.Launcher/Converters/CornerRadiusFilterConverter.cs b/Flow.Launcher/Converters/CornerRadiusFilterConverter.cs new file mode 100644 index 000000000..fd43cafac --- /dev/null +++ b/Flow.Launcher/Converters/CornerRadiusFilterConverter.cs @@ -0,0 +1,91 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Flow.Launcher.Converters; + +public class CornerRadiusFilterConverter : DependencyObject, IValueConverter +{ + public CornerRadiusFilterKind Filter { get; set; } + + public double Scale { get; set; } = 1.0; + + public static CornerRadius Convert(CornerRadius radius, CornerRadiusFilterKind filterKind) + { + CornerRadius result = radius; + + switch (filterKind) + { + case CornerRadiusFilterKind.Top: + result.BottomLeft = 0; + result.BottomRight = 0; + break; + case CornerRadiusFilterKind.Right: + result.TopLeft = 0; + result.BottomLeft = 0; + break; + case CornerRadiusFilterKind.Bottom: + result.TopLeft = 0; + result.TopRight = 0; + break; + case CornerRadiusFilterKind.Left: + result.TopRight = 0; + result.BottomRight = 0; + break; + } + + return result; + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var cornerRadius = (CornerRadius)value; + + var scale = Scale; + if (!double.IsNaN(scale)) + { + cornerRadius.TopLeft *= scale; + cornerRadius.TopRight *= scale; + cornerRadius.BottomRight *= scale; + cornerRadius.BottomLeft *= scale; + } + + var filterType = Filter; + if (filterType == CornerRadiusFilterKind.TopLeftValue || + filterType == CornerRadiusFilterKind.BottomRightValue) + { + return GetDoubleValue(cornerRadius, filterType); + } + + return Convert(cornerRadius, filterType); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static double GetDoubleValue(CornerRadius radius, CornerRadiusFilterKind filterKind) + { + switch (filterKind) + { + case CornerRadiusFilterKind.TopLeftValue: + return radius.TopLeft; + case CornerRadiusFilterKind.BottomRightValue: + return radius.BottomRight; + } + return 0; + } +} + +public enum CornerRadiusFilterKind +{ + None, + Top, + Right, + Bottom, + Left, + TopLeftValue, + BottomRightValue +} diff --git a/Flow.Launcher/Converters/PlacementRectangleConverter.cs b/Flow.Launcher/Converters/PlacementRectangleConverter.cs new file mode 100644 index 000000000..130d04e16 --- /dev/null +++ b/Flow.Launcher/Converters/PlacementRectangleConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Flow.Launcher.Converters; + +public class PlacementRectangleConverter : IMultiValueConverter +{ + public Thickness Margin { get; set; } + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length == 2 && + values[0] is double width && + values[1] is double height) + { + var margin = Margin; + var topLeft = new Point(margin.Left, margin.Top); + var bottomRight = new Point(width - margin.Right, height - margin.Bottom); + var rect = new Rect(topLeft, bottomRight); + return rect; + } + + return Rect.Empty; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs b/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs index eb492e334..c2d7d016c 100644 --- a/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs +++ b/Flow.Launcher/Converters/QuerySuggestionBoxConverter.cs @@ -33,15 +33,39 @@ public class QuerySuggestionBoxConverter : IMultiValueConverter { var selectedResult = selectedItem.Result; var selectedResultActionKeyword = string.IsNullOrEmpty(selectedResult.ActionKeywordAssigned) ? "" : selectedResult.ActionKeywordAssigned + " "; - var selectedResultPossibleSuggestion = selectedResultActionKeyword + selectedResult.Title; - if (!selectedResultPossibleSuggestion.StartsWith(queryText, StringComparison.CurrentCultureIgnoreCase)) + string selectedResultPossibleSuggestion = null; + + // Firstly check if the result has QuerySuggestionText + if (!string.IsNullOrEmpty(selectedResult.QuerySuggestionText)) + { + selectedResultPossibleSuggestion = selectedResultActionKeyword + selectedResult.QuerySuggestionText; + + // If this QuerySuggestionText does not start with the queryText, set it to null + if (!selectedResultPossibleSuggestion.StartsWith(queryText, StringComparison.CurrentCultureIgnoreCase)) + { + selectedResultPossibleSuggestion = null; + } + } + + // Then check Title as suggestion + if (string.IsNullOrEmpty(selectedResultPossibleSuggestion)) + { + selectedResultPossibleSuggestion = selectedResultActionKeyword + selectedResult.Title; + + // If this QuerySuggestionText does not start with the queryText, set it to null + if (!selectedResultPossibleSuggestion.StartsWith(queryText, StringComparison.CurrentCultureIgnoreCase)) + { + selectedResultPossibleSuggestion = null; + } + } + + if (string.IsNullOrEmpty(selectedResultPossibleSuggestion)) return string.Empty; - // For AutocompleteQueryCommand. // When user typed lower case and result title is uppercase, we still want to display suggestion - selectedItem.QuerySuggestionText = queryText + selectedResultPossibleSuggestion.Substring(queryText.Length); + selectedItem.QuerySuggestionText = string.Concat(queryText, selectedResultPossibleSuggestion.AsSpan(queryText.Length)); // Check if Text will be larger than our QueryTextBox Typeface typeface = new Typeface(queryTextBox.FontFamily, queryTextBox.FontStyle, queryTextBox.FontWeight, queryTextBox.FontStretch); diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Views/Converters/EverythingEnumNameConverter.cs b/Flow.Launcher/Converters/SharedSizeGroupConverter.cs similarity index 52% rename from Plugins/Flow.Launcher.Plugin.Explorer/Views/Converters/EverythingEnumNameConverter.cs rename to Flow.Launcher/Converters/SharedSizeGroupConverter.cs index e24b21dcd..594787027 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Views/Converters/EverythingEnumNameConverter.cs +++ b/Flow.Launcher/Converters/SharedSizeGroupConverter.cs @@ -1,20 +1,19 @@ -using Flow.Launcher.Plugin.Everything.Everything; -using Flow.Launcher.Plugin.Explorer.Helper; -using System; +using System; using System.Globalization; +using System.Windows; using System.Windows.Data; -namespace Flow.Launcher.Plugin.Explorer.Views.Converters; +namespace Flow.Launcher.Converters; -public class EnumNameConverter : IValueConverter +public class SharedSizeGroupConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return value is SortOption option ? option.GetTranslatedName() : value; + return (Visibility)value != Visibility.Collapsed ? (string)parameter : null; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Converters/StringToKeyBindingConverter.cs b/Flow.Launcher/Converters/StringToKeyBindingConverter.cs index 21bf584e7..b7bca41c5 100644 --- a/Flow.Launcher/Converters/StringToKeyBindingConverter.cs +++ b/Flow.Launcher/Converters/StringToKeyBindingConverter.cs @@ -5,7 +5,7 @@ using System.Windows.Input; namespace Flow.Launcher.Converters; -class StringToKeyBindingConverter : IValueConverter +public class StringToKeyBindingConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { diff --git a/Flow.Launcher/CustomQueryHotkeySetting.xaml b/Flow.Launcher/CustomQueryHotkeySetting.xaml index 0171e6d79..db99b704a 100644 --- a/Flow.Launcher/CustomQueryHotkeySetting.xaml +++ b/Flow.Launcher/CustomQueryHotkeySetting.xaml @@ -119,7 +119,8 @@ Grid.Column="1" Margin="10" HorizontalAlignment="Stretch" - VerticalAlignment="Center" /> + VerticalAlignment="Center" + Text="{Binding ActionKeyword}" /> diff --git a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs index 77febde9d..3bba2c5b8 100644 --- a/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs +++ b/Flow.Launcher/CustomQueryHotkeySetting.xaml.cs @@ -1,73 +1,52 @@ -using System.Collections.ObjectModel; -using System.Linq; -using System.Windows; -using System.Windows.Input; +using System.Windows; using System.Windows.Controls; -using Flow.Launcher.Helper; +using System.Windows.Input; using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher { public partial class CustomQueryHotkeySetting : Window { - private readonly Settings _settings; + public string Hotkey { get; set; } = string.Empty; + public string ActionKeyword { get; set; } = string.Empty; - private bool update; - private CustomPluginHotkey updateCustomHotkey; + private readonly bool update; + private readonly CustomPluginHotkey originalCustomHotkey; - public CustomQueryHotkeySetting(Settings settings) + public CustomQueryHotkeySetting() { - _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.ToString(); + + if (string.IsNullOrEmpty(Hotkey) && string.IsNullOrEmpty(ActionKeyword)) { - _settings.CustomPluginHotkeys ??= new ObservableCollection(); - - var pluginHotkey = new CustomPluginHotkey - { - Hotkey = HotkeyControl.CurrentHotkey.ToString(), ActionKeyword = tbAction.Text - }; - _settings.CustomPluginHotkeys.Add(pluginHotkey); - - HotKeyMapper.SetCustomQueryHotkey(pluginHotkey); - } - else - { - var oldHotkey = updateCustomHotkey.Hotkey; - updateCustomHotkey.ActionKeyword = tbAction.Text; - updateCustomHotkey.Hotkey = HotkeyControl.CurrentHotkey.ToString(); - //remove origin hotkey - HotKeyMapper.RemoveHotkey(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) - { - App.API.ShowMsgBox(App.API.GetTranslation("invalidPluginHotkey")); - Close(); + App.API.ShowMsgBox(Localize.emptyPluginHotkey()); return; } - tbAction.Text = updateCustomHotkey.ActionKeyword; - HotkeyControl.SetHotkey(updateCustomHotkey.Hotkey, false); - update = true; - lblAdd.Text = App.API.GetTranslation("update"); + DialogResult = !update || originalCustomHotkey.Hotkey != Hotkey || originalCustomHotkey.ActionKeyword != ActionKeyword; + Close(); } private void BtnTestActionKeyword_OnClick(object sender, RoutedEventArgs e) @@ -79,6 +58,7 @@ namespace Flow.Launcher private void cmdEsc_OnPress(object sender, ExecutedRoutedEventArgs e) { + DialogResult = false; Close(); } diff --git a/Flow.Launcher/CustomShortcutSetting.xaml b/Flow.Launcher/CustomShortcutSetting.xaml index d8623753f..5185354e7 100644 --- a/Flow.Launcher/CustomShortcutSetting.xaml +++ b/Flow.Launcher/CustomShortcutSetting.xaml @@ -118,24 +118,26 @@ FontSize="14" Text="{DynamicResource customShortcutExpansion}" /> - - + + + + + + + + + + + + + + + + + - - + + + + + + + - - + + + + + + + + + + + + + - - - - - + + + + + + + + - - - - - - + ToolTipService.Placement="Top" /> + - - + VirtualizingPanel.ScrollUnit="Pixel" + VirtualizingPanel.VirtualizationMode="Recycling"> + - - + + + - - - + ToolTipService.Placement="Top" /> + - - + - - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + - +