Merge branch 'dev' into dependabot/nuget/System.Data.OleDb-9.0.3

This commit is contained in:
Jack Ye 2025-07-19 19:34:19 +08:00 committed by GitHub
commit 48822fffa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
591 changed files with 28073 additions and 10061 deletions

View file

@ -16,6 +16,8 @@ body:
I have checked that this issue has not already been reported.
- label: >
I am using the latest version of Flow Launcher.
- label: >
I am using the prerelease version of Flow Launcher.
- type: textarea
attributes:

View file

@ -4,5 +4,4 @@ ssh
ubuntu
runcount
Firefox
Português
Português (Brasil)
workaround

View file

@ -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,20 @@ 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

View file

@ -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,23 @@
# version suffix <word>v#
(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[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

240
.github/update_release_pr.py vendored Normal file
View file

@ -0,0 +1,240 @@
from os import getenv
from typing import Optional
import requests
def get_github_prs(token: str, owner: str, repo: str, label: str = "", state: str = "all") -> list[dict]:
"""
Fetches pull requests from a GitHub repository that match a given label and state.
Args:
token (str): GitHub token.
owner (str): The owner of the repository.
repo (str): The name of the repository.
label (str): The label name. Filter is not applied when empty string.
state (str): State of PR, e.g. open, closed, all
Returns:
list: A list of dictionaries, where each dictionary represents a pull request.
Returns an empty list if no PRs are found or an error occurs.
"""
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
}
# This endpoint allows filtering by label(and milestone). A PR in GH's perspective is a type of issue.
prs_url = f"https://api.github.com/repos/{owner}/{repo}/issues"
params = {
"state": state,
"labels": label,
"per_page": 100,
}
all_prs = []
page = 1
while True:
try:
params["page"] = page
response = requests.get(prs_url, headers=headers, params=params)
response.raise_for_status() # Raise an exception for HTTP errors
prs = response.json()
if not prs:
break # No more PRs to fetch
# Check for pr key since we are using issues endpoint instead.
all_prs.extend([item for item in prs if "pull_request" in item])
page += 1
except requests.exceptions.RequestException as e:
print(f"Error fetching pull requests: {e}")
exit(1)
return all_prs
def get_prs(
pull_request_items: list[dict], label: str = "", state: str = "all", milestone_title: Optional[str] = None
) -> list[dict]:
"""
Returns a list of pull requests after applying the label and state filters.
Args:
pull_request_items (list[dict]): List of PR items.
label (str): The label name. Filter is not applied when empty string.
state (str): State of PR, e.g. open, closed, all
milestone_title (Optional[str]): The milestone title to filter by. This is the milestone number you created
in GitHub, e.g. '1.20.0'. If None, no milestone filtering is applied.
Returns:
list: A list of dictionaries, where each dictionary represents a pull request.
Returns an empty list if no PRs are found.
"""
pr_list = []
count = 0
for pr in pull_request_items:
if state not in [pr["state"], "all"]:
continue
if label and not [item for item in pr["labels"] if item["name"] == label]:
continue
if milestone_title:
if pr["milestone"] is None or pr["milestone"]["title"] != milestone_title:
continue
pr_list.append(pr)
count += 1
print(
f"Found {count} PRs with {label if label else 'no filter on'} label, state as {state}, and milestone {milestone_title if milestone_title else "any"}"
)
return pr_list
def get_prs_assignees(pull_request_items: list[dict]) -> list[str]:
"""
Returns a list of pull request assignees, excludes jjw24.
Args:
pull_request_items (list[dict]): List of PR items to get the assignees from.
Returns:
list: A list of strs, where each string is an assignee name. List is not distinct, so can contain
duplicate names.
Returns an empty list if none are found.
"""
assignee_list = []
for pr in pull_request_items:
[assignee_list.append(assignee["login"]) for assignee in pr["assignees"] if assignee["login"] != "jjw24"]
print(f"Found {len(assignee_list)} assignees")
return assignee_list
def get_pr_descriptions(pull_request_items: list[dict]) -> str:
"""
Returns the concatenated string of pr title and number in the format of
'- PR title 1 #3651
- PR title 2 #3652
- PR title 3 #3653
'
Args:
pull_request_items (list[dict]): List of PR items.
Returns:
str: a string of PR titles and numbers
"""
description_content = ""
for pr in pull_request_items:
description_content += f"- {pr['title']} #{pr['number']}\n"
return description_content
def update_pull_request_description(token: str, owner: str, repo: str, pr_number: int, new_description: str) -> None:
"""
Updates the description (body) of a GitHub Pull Request.
Args:
token (str): Token.
owner (str): The owner of the repository.
repo (str): The name of the repository.
pr_number (int): The number of the pull request to update.
new_description (str): The new content for the PR's description.
Returns:
dict or None: The updated PR object (as a dictionary) if successful,
None otherwise.
"""
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
}
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
payload = {"body": new_description}
print(f"Attempting to update PR #{pr_number} in {owner}/{repo}...")
print(f"URL: {url}")
try:
response = None
response = requests.patch(url, headers=headers, json=payload)
response.raise_for_status()
print(f"Successfully updated PR #{pr_number}.")
except requests.exceptions.RequestException as e:
print(f"Error updating pull request #{pr_number}: {e}")
if response is not None:
print(f"Response status code: {response.status_code}")
print(f"Response text: {response.text}")
exit(1)
if __name__ == "__main__":
github_token = getenv("GITHUB_TOKEN")
if not github_token:
print("Error: GITHUB_TOKEN environment variable not set.")
exit(1)
repository_owner = "flow-launcher"
repository_name = "flow.launcher"
state = "all"
print(f"Fetching {state} PRs for {repository_owner}/{repository_name} ...")
# First, get all PRs to find the release PR and determine the milestone
all_pull_requests = get_github_prs(github_token, repository_owner, repository_name)
if not all_pull_requests:
print("No pull requests found")
exit(1)
print(f"\nFound total of {len(all_pull_requests)} pull requests")
release_pr = get_prs(all_pull_requests, "release", "open")
if len(release_pr) != 1:
print(f"Unable to find the exact release PR. Returned result: {release_pr}")
exit(1)
print(f"Found release PR: {release_pr[0]['title']}")
release_milestone_title = release_pr[0].get("milestone", {}).get("title", None)
if not release_milestone_title:
print("Release PR does not have a milestone assigned.")
exit(1)
print(f"Using milestone number: {release_milestone_title}")
enhancement_prs = get_prs(all_pull_requests, "enhancement", "closed", release_milestone_title)
bug_fix_prs = get_prs(all_pull_requests, "bug", "closed", release_milestone_title)
if len(enhancement_prs) == 0 and len(bug_fix_prs) == 0:
print(f"No PRs with {release_milestone_title} milestone were found")
description_content = "# Release notes\n"
description_content += f"## Features\n{get_pr_descriptions(enhancement_prs)}" if enhancement_prs else ""
description_content += f"## Bug fixes\n{get_pr_descriptions(bug_fix_prs)}" if bug_fix_prs else ""
assignees = list(set(get_prs_assignees(enhancement_prs) + get_prs_assignees(bug_fix_prs)))
assignees.sort(key=str.lower)
description_content += f"### Authors:\n{', '.join(assignees)}"
update_pull_request_description(
github_token, repository_owner, repository_name, release_pr[0]["number"], description_content
)
print(f"PR content updated to:\n{description_content}")

View file

@ -3,11 +3,10 @@ name: Publish Default Plugins
on:
push:
branches: ['master']
paths: ['Plugins/**']
workflow_dispatch:
jobs:
build:
publish:
runs-on: windows-latest
steps:
@ -15,41 +14,26 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
dotnet-version: 9.0.x
- name: Determine New Plugin Updates
uses: dorny/paths-filter@v3
id: changes
with:
filters: |
browserbookmark:
- 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/plugin.json'
calculator:
- 'Plugins/Flow.Launcher.Plugin.Calculator/plugin.json'
explorer:
- 'Plugins/Flow.Launcher.Plugin.Explorer/plugin.json'
pluginindicator:
- 'Plugins/Flow.Launcher.Plugin.PluginIndicator/plugin.json'
pluginsmanager:
- 'Plugins/Flow.Launcher.Plugin.PluginsManager/plugin.json'
processkiller:
- 'Plugins/Flow.Launcher.Plugin.ProcessKiller/plugin.json'
program:
- 'Plugins/Flow.Launcher.Plugin.Program/plugin.json'
shell:
- 'Plugins/Flow.Launcher.Plugin.Shell/plugin.json'
sys:
- 'Plugins/Flow.Launcher.Plugin.Sys/plugin.json'
url:
- 'Plugins/Flow.Launcher.Plugin.Url/plugin.json'
websearch:
- 'Plugins/Flow.Launcher.Plugin.WebSearch/plugin.json'
windowssettings:
- 'Plugins/Flow.Launcher.Plugin.WindowsSettings/plugin.json'
base: 'master'
- name: Update Plugins To Production Version
run: |
$version = "1.0.0"
Get-Content appveyor.yml | ForEach-Object {
if ($_ -match "version:\s*'(\d+\.\d+\.\d+)\.") {
$version = $matches[1]
}
}
$jsonFiles = Get-ChildItem -Path ".\Plugins\*\plugin.json"
foreach ($file in $jsonFiles) {
$plugin_old_ver = Get-Content $file.FullName -Raw | ConvertFrom-Json
(Get-Content $file) -replace '"Version"\s*:\s*".*?"', "`"Version`": `"$version`"" | Set-Content $file
$plugin_new_ver = Get-Content $file.FullName -Raw | ConvertFrom-Json
Write-Host "Updated" $plugin_old_ver.Name "version from" $plugin_old_ver.Version "to" $plugin_new_ver.Version
}
- name: Get BrowserBookmark Version
if: steps.changes.outputs.browserbookmark == 'true'
id: updated-version-browserbookmark
uses: notiz-dev/github-action-json-property@release
with:
@ -57,14 +41,12 @@ jobs:
prop_path: 'Version'
- name: Build BrowserBookmark
if: steps.changes.outputs.browserbookmark == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.BrowserBookmark"
dotnet publish 'Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.BrowserBookmark"
7z a -tzip "Flow.Launcher.Plugin.BrowserBookmark.zip" "./Flow.Launcher.Plugin.BrowserBookmark/*"
rm -r "Flow.Launcher.Plugin.BrowserBookmark"
- name: Publish BrowserBookmark
if: steps.changes.outputs.browserbookmark == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.BrowserBookmark"
@ -76,7 +58,6 @@ jobs:
- name: Get Calculator Version
if: steps.changes.outputs.calculator == 'true'
id: updated-version-calculator
uses: notiz-dev/github-action-json-property@release
with:
@ -84,14 +65,12 @@ jobs:
prop_path: 'Version'
- name: Build Calculator
if: steps.changes.outputs.calculator == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Calculator/Flow.Launcher.Plugin.Calculator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Calculator"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Calculator/Flow.Launcher.Plugin.Calculator.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.Calculator"
7z a -tzip "Flow.Launcher.Plugin.Calculator.zip" "./Flow.Launcher.Plugin.Calculator/*"
rm -r "Flow.Launcher.Plugin.Calculator"
- name: Publish Calculator
if: steps.changes.outputs.calculator == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Calculator"
@ -103,7 +82,6 @@ jobs:
- name: Get Explorer Version
if: steps.changes.outputs.explorer == 'true'
id: updated-version-explorer
uses: notiz-dev/github-action-json-property@release
with:
@ -111,14 +89,12 @@ jobs:
prop_path: 'Version'
- name: Build Explorer
if: steps.changes.outputs.explorer == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Explorer"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.Explorer"
7z a -tzip "Flow.Launcher.Plugin.Explorer.zip" "./Flow.Launcher.Plugin.Explorer/*"
rm -r "Flow.Launcher.Plugin.Explorer"
- name: Publish Explorer
if: steps.changes.outputs.explorer == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Explorer"
@ -130,7 +106,6 @@ jobs:
- name: Get PluginIndicator Version
if: steps.changes.outputs.pluginindicator == 'true'
id: updated-version-pluginindicator
uses: notiz-dev/github-action-json-property@release
with:
@ -138,14 +113,12 @@ jobs:
prop_path: 'Version'
- name: Build PluginIndicator
if: steps.changes.outputs.pluginindicator == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginIndicator/Flow.Launcher.Plugin.PluginIndicator.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginIndicator"
dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginIndicator/Flow.Launcher.Plugin.PluginIndicator.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.PluginIndicator"
7z a -tzip "Flow.Launcher.Plugin.PluginIndicator.zip" "./Flow.Launcher.Plugin.PluginIndicator/*"
rm -r "Flow.Launcher.Plugin.PluginIndicator"
- name: Publish PluginIndicator
if: steps.changes.outputs.pluginindicator == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginIndicator"
@ -157,7 +130,6 @@ jobs:
- name: Get PluginsManager Version
if: steps.changes.outputs.pluginsmanager == 'true'
id: updated-version-pluginsmanager
uses: notiz-dev/github-action-json-property@release
with:
@ -165,14 +137,12 @@ jobs:
prop_path: 'Version'
- name: Build PluginsManager
if: steps.changes.outputs.pluginsmanager == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.PluginsManager"
dotnet publish 'Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.PluginsManager"
7z a -tzip "Flow.Launcher.Plugin.PluginsManager.zip" "./Flow.Launcher.Plugin.PluginsManager/*"
rm -r "Flow.Launcher.Plugin.PluginsManager"
- name: Publish PluginsManager
if: steps.changes.outputs.pluginsmanager == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.PluginsManager"
@ -184,7 +154,6 @@ jobs:
- name: Get ProcessKiller Version
if: steps.changes.outputs.processkiller == 'true'
id: updated-version-processkiller
uses: notiz-dev/github-action-json-property@release
with:
@ -192,14 +161,12 @@ jobs:
prop_path: 'Version'
- name: Build ProcessKiller
if: steps.changes.outputs.processkiller == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.ProcessKiller/Flow.Launcher.Plugin.ProcessKiller.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.ProcessKiller"
dotnet publish 'Plugins/Flow.Launcher.Plugin.ProcessKiller/Flow.Launcher.Plugin.ProcessKiller.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.ProcessKiller"
7z a -tzip "Flow.Launcher.Plugin.ProcessKiller.zip" "./Flow.Launcher.Plugin.ProcessKiller/*"
rm -r "Flow.Launcher.Plugin.ProcessKiller"
- name: Publish ProcessKiller
if: steps.changes.outputs.processkiller == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.ProcessKiller"
@ -211,7 +178,6 @@ jobs:
- name: Get Program Version
if: steps.changes.outputs.program == 'true'
id: updated-version-program
uses: notiz-dev/github-action-json-property@release
with:
@ -219,14 +185,12 @@ jobs:
prop_path: 'Version'
- name: Build Program
if: steps.changes.outputs.program == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj' --framework net7.0-windows10.0.19041.0 -c Release -o "Flow.Launcher.Plugin.Program"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Program/Flow.Launcher.Plugin.Program.csproj' --framework net9.0-windows10.0.19041.0 -c Release -o "Flow.Launcher.Plugin.Program"
7z a -tzip "Flow.Launcher.Plugin.Program.zip" "./Flow.Launcher.Plugin.Program/*"
rm -r "Flow.Launcher.Plugin.Program"
- name: Publish Program
if: steps.changes.outputs.program == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Program"
@ -238,7 +202,6 @@ jobs:
- name: Get Shell Version
if: steps.changes.outputs.shell == 'true'
id: updated-version-shell
uses: notiz-dev/github-action-json-property@release
with:
@ -246,14 +209,12 @@ jobs:
prop_path: 'Version'
- name: Build Shell
if: steps.changes.outputs.shell == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Shell"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.Shell"
7z a -tzip "Flow.Launcher.Plugin.Shell.zip" "./Flow.Launcher.Plugin.Shell/*"
rm -r "Flow.Launcher.Plugin.Shell"
- name: Publish Shell
if: steps.changes.outputs.shell == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Shell"
@ -265,7 +226,6 @@ jobs:
- name: Get Sys Version
if: steps.changes.outputs.sys == 'true'
id: updated-version-sys
uses: notiz-dev/github-action-json-property@release
with:
@ -273,14 +233,12 @@ jobs:
prop_path: 'Version'
- name: Build Sys
if: steps.changes.outputs.sys == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Sys"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.Sys"
7z a -tzip "Flow.Launcher.Plugin.Sys.zip" "./Flow.Launcher.Plugin.Sys/*"
rm -r "Flow.Launcher.Plugin.Sys"
- name: Publish Sys
if: steps.changes.outputs.sys == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Sys"
@ -292,7 +250,6 @@ jobs:
- name: Get Url Version
if: steps.changes.outputs.url == 'true'
id: updated-version-url
uses: notiz-dev/github-action-json-property@release
with:
@ -300,14 +257,12 @@ jobs:
prop_path: 'Version'
- name: Build Url
if: steps.changes.outputs.url == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.Url/Flow.Launcher.Plugin.Url.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.Url"
dotnet publish 'Plugins/Flow.Launcher.Plugin.Url/Flow.Launcher.Plugin.Url.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.Url"
7z a -tzip "Flow.Launcher.Plugin.Url.zip" "./Flow.Launcher.Plugin.Url/*"
rm -r "Flow.Launcher.Plugin.Url"
- name: Publish Url
if: steps.changes.outputs.url == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.Url"
@ -319,7 +274,6 @@ jobs:
- name: Get WebSearch Version
if: steps.changes.outputs.websearch == 'true'
id: updated-version-websearch
uses: notiz-dev/github-action-json-property@release
with:
@ -327,14 +281,12 @@ jobs:
prop_path: 'Version'
- name: Build WebSearch
if: steps.changes.outputs.websearch == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.WebSearch/Flow.Launcher.Plugin.WebSearch.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WebSearch"
dotnet publish 'Plugins/Flow.Launcher.Plugin.WebSearch/Flow.Launcher.Plugin.WebSearch.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.WebSearch"
7z a -tzip "Flow.Launcher.Plugin.WebSearch.zip" "./Flow.Launcher.Plugin.WebSearch/*"
rm -r "Flow.Launcher.Plugin.WebSearch"
- name: Publish WebSearch
if: steps.changes.outputs.websearch == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.WebSearch"
@ -346,7 +298,6 @@ jobs:
- name: Get WindowsSettings Version
if: steps.changes.outputs.windowssettings == 'true'
id: updated-version-windowssettings
uses: notiz-dev/github-action-json-property@release
with:
@ -354,14 +305,12 @@ jobs:
prop_path: 'Version'
- name: Build WindowsSettings
if: steps.changes.outputs.windowssettings == 'true'
run: |
dotnet publish 'Plugins/Flow.Launcher.Plugin.WindowsSettings/Flow.Launcher.Plugin.WindowsSettings.csproj' --framework net7.0-windows -c Release -o "Flow.Launcher.Plugin.WindowsSettings"
dotnet publish 'Plugins/Flow.Launcher.Plugin.WindowsSettings/Flow.Launcher.Plugin.WindowsSettings.csproj' --framework net9.0-windows -c Release -o "Flow.Launcher.Plugin.WindowsSettings"
7z a -tzip "Flow.Launcher.Plugin.WindowsSettings.zip" "./Flow.Launcher.Plugin.WindowsSettings/*"
rm -r "Flow.Launcher.Plugin.WindowsSettings"
- name: Publish WindowsSettings
if: steps.changes.outputs.windowssettings == 'true'
uses: softprops/action-gh-release@v2
with:
repository: "Flow-Launcher/Flow.Launcher.Plugin.WindowsSettings"

91
.github/workflows/dotnet.yml vendored Normal file
View file

@ -0,0 +1,91 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
name: Build
on:
workflow_dispatch:
push:
branches:
- dev
- master
pull_request:
jobs:
build:
runs-on: windows-latest
env:
FlowVersion: 1.20.2
NUGET_CERT_REVOCATION_MODE: offline
BUILD_NUMBER: ${{ github.run_number }}
steps:
- uses: actions/checkout@v4
- name: Set Flow.Launcher.csproj version
id: update
uses: vers-one/dotnet-project-version-updater@v1.7
with:
file: |
"**/SolutionAssemblyInfo.cs"
version: ${{ env.FlowVersion }}.${{ env.BUILD_NUMBER }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
# cache: true
# cache-dependency-path: |
# Flow.Launcher/packages.lock.json
# Flow.Launcher.Core/packages.lock.json
# Flow.Launcher.Infrastructure/packages.lock.json
# Flow.Launcher.Plugin/packages.lock.json
- name: Install vpk
run: dotnet tool install -g vpk
- name: Restore dependencies
run: nuget restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Initialize Service
run: |
sc config WSearch start= auto # Starts Windows Search service- Needed for running ExplorerTest
net start WSearch
- name: Test
run: dotnet test --no-build --verbosity normal -c Release
- name: Perform post_build tasks
shell: powershell
run: .\Scripts\post_build.ps1
- name: Upload Plugin Nupkg
uses: actions/upload-artifact@v4
with:
name: Plugin nupkg
path: |
Output\Release\Flow.Launcher.Plugin.*.nupkg
compression-level: 0
- name: Upload Setup
uses: actions/upload-artifact@v4
with:
name: Flow Installer
path: |
Output\Packages\Flow-Launcher-*.exe
compression-level: 0
- name: Upload Portable Version
uses: actions/upload-artifact@v4
with:
name: Portable Version
path: |
Output\Packages\Flow-Launcher-Portable.zip
compression-level: 0
- name: Upload Full Nupkg
uses: actions/upload-artifact@v4
with:
name: Full nupkg
path: |
Output\Packages\FlowLauncher-*-full.nupkg
compression-level: 0
- name: Upload Release Information
uses: actions/upload-artifact@v4
with:
name: RELEASES
path: |
Output\Packages\RELEASES
compression-level: 0

34
.github/workflows/release_deploy.yml vendored Normal file
View file

@ -0,0 +1,34 @@
---
name: New Release Deployments
on:
release:
types: [published]
workflow_dispatch:
jobs:
deploy-website:
runs-on: ubuntu-latest
steps:
- name: Trigger dispatch event for deploying website
run: |
http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_FLOW_WEBSITE }}" \
https://api.github.com/repos/Flow-Launcher/flow-launcher.github.io/dispatches \
-d '{"event_type":"deploy"}')
if [ "$http_status" -ne 204 ]; then echo "Error: Deploy website failed, HTTP status code is $http_status"; exit 1; fi
publish-chocolatey:
runs-on: ubuntu-latest
steps:
- name: Trigger dispatch event for publishing to Chocolatey
run: |
http_status=$(curl -L -f -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.Publish_Chocolatey }}" \
https://api.github.com/repos/Flow-Launcher/chocolatey-package/dispatches \
-d '{"event_type":"publish"}')
if [ "$http_status" -ne 204 ]; then echo "Error: Publish Chocolatey package failed, HTTP status code is $http_status"; exit 1; fi

25
.github/workflows/release_pr.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Update release PR
on:
pull_request:
types: [opened, reopened, synchronize]
branches:
- master
workflow_dispatch:
jobs:
update-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Run release PR update
env:
GITHUB_TOKEN: ${{ secrets.PR_TOKEN }}
run: |
pip install requests -q
python3 ./.github/update_release_pr.py

View file

@ -41,9 +41,8 @@ on:
# tags-ignore:
# - "**"
pull_request_target:
branches:
- '**'
# - '!l10n_dev'
branches-ignore:
- master
tags-ignore:
- "**"
types:
@ -73,7 +72,7 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@prerelease
uses: check-spelling/check-spelling@v0.0.25
with:
suppress_push_for_open_pull_request: 1
checkout: true
@ -129,7 +128,7 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@prerelease
uses: check-spelling/check-spelling@v0.0.25
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@main

View file

@ -1,28 +1,29 @@
using Microsoft.Win32;
using Squirrel;
using System;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Windows;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin.SharedCommands;
using System.Linq;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using Microsoft.Win32;
using Squirrel;
namespace Flow.Launcher.Core.Configuration
{
public class Portable : IPortable
{
private static readonly string ClassName = nameof(Portable);
private readonly IPublicAPI API = Ioc.Default.GetRequiredService<IPublicAPI>();
/// <summary>
/// As at Squirrel.Windows version 1.5.2, UpdateManager needs to be disposed after finish
/// </summary>
/// <returns></returns>
private UpdateManager NewUpdateManager()
private static UpdateManager NewUpdateManager()
{
var applicationFolderName = Constant.ApplicationDirectory
.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None)
@ -51,7 +52,7 @@ namespace Flow.Launcher.Core.Configuration
}
catch (Exception e)
{
Log.Exception("|Portable.DisablePortableMode|Error occurred while disabling portable mode", e);
API.LogException(ClassName, "Error occurred while disabling portable mode", e);
}
}
@ -75,26 +76,22 @@ namespace Flow.Launcher.Core.Configuration
}
catch (Exception e)
{
Log.Exception("|Portable.EnablePortableMode|Error occurred while enabling portable mode", e);
API.LogException(ClassName, "Error occurred while enabling portable mode", e);
}
}
public void RemoveShortcuts()
{
using (var portabilityUpdater = NewUpdateManager())
{
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu);
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop);
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup);
}
using var portabilityUpdater = NewUpdateManager();
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu);
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop);
portabilityUpdater.RemoveShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup);
}
public void RemoveUninstallerEntry()
{
using (var portabilityUpdater = NewUpdateManager())
{
portabilityUpdater.RemoveUninstallerRegistryEntry();
}
using var portabilityUpdater = NewUpdateManager();
portabilityUpdater.RemoveUninstallerRegistryEntry();
}
public void MoveUserDataFolder(string fromLocation, string toLocation)
@ -110,12 +107,10 @@ namespace Flow.Launcher.Core.Configuration
public void CreateShortcuts()
{
using (var portabilityUpdater = NewUpdateManager())
{
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false);
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false);
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false);
}
using var portabilityUpdater = NewUpdateManager();
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.StartMenu, false);
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Desktop, false);
portabilityUpdater.CreateShortcutsForExecutable(Constant.ApplicationFileName, ShortcutLocation.Startup, false);
}
public void CreateUninstallerEntry()
@ -129,18 +124,14 @@ namespace Flow.Launcher.Core.Configuration
subKey2.SetValue("DisplayIcon", Path.Combine(Constant.ApplicationDirectory, "app.ico"), RegistryValueKind.String);
}
using (var portabilityUpdater = NewUpdateManager())
{
_ = portabilityUpdater.CreateUninstallerRegistryEntry();
}
using var portabilityUpdater = NewUpdateManager();
_ = portabilityUpdater.CreateUninstallerRegistryEntry();
}
internal void IndicateDeletion(string filePathTodelete)
private static void IndicateDeletion(string filePathTodelete)
{
var deleteFilePath = Path.Combine(filePathTodelete, DataLocation.DeletionIndicatorFile);
using (var _ = File.CreateText(deleteFilePath))
{
}
using var _ = File.CreateText(deleteFilePath);
}
///<summary>

View file

@ -1,24 +1,32 @@
using Flow.Launcher.Infrastructure.Http;
using Flow.Launcher.Infrastructure.Logger;
using System;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Http;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins
{
public record CommunityPluginSource(string ManifestFileUrl)
{
private static readonly string ClassName = nameof(CommunityPluginSource);
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
private string latestEtag = "";
private List<UserPlugin> plugins = new();
private static JsonSerializerOptions PluginStoreItemSerializationOption = new JsonSerializerOptions()
private static readonly JsonSerializerOptions PluginStoreItemSerializationOption = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault
};
@ -33,35 +41,49 @@ namespace Flow.Launcher.Core.ExternalPlugins
/// </remarks>
public async Task<List<UserPlugin>> FetchAsync(CancellationToken token)
{
Log.Info(nameof(CommunityPluginSource), $"Loading plugins from {ManifestFileUrl}");
API.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}");
var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl);
request.Headers.Add("If-None-Match", latestEtag);
using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token)
try
{
using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.OK)
{
this.plugins = await response.Content
.ReadFromJsonAsync<List<UserPlugin>>(PluginStoreItemSerializationOption, cancellationToken: token)
.ConfigureAwait(false);
this.latestEtag = response.Headers.ETag?.Tag;
if (response.StatusCode == HttpStatusCode.OK)
{
plugins = await response.Content
.ReadFromJsonAsync<List<UserPlugin>>(PluginStoreItemSerializationOption, cancellationToken: token)
.ConfigureAwait(false);
latestEtag = response.Headers.ETag?.Tag;
Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}");
return this.plugins;
API.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrl}");
return plugins;
}
else if (response.StatusCode == HttpStatusCode.NotModified)
{
API.LogInfo(ClassName, $"Resource {ManifestFileUrl} has not been modified.");
return plugins;
}
else
{
API.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
return null;
}
}
else if (response.StatusCode == HttpStatusCode.NotModified)
catch (Exception e)
{
Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified.");
return this.plugins;
}
else
{
Log.Warn(nameof(CommunityPluginSource),
$"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException)
{
API.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrl}.", e);
}
else
{
API.LogException(ClassName, "Error Occurred", e);
}
return null;
}
}
}

View file

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins
{
@ -39,10 +40,14 @@ namespace Flow.Launcher.Core.ExternalPlugins
var completedTask = await Task.WhenAny(tasks);
if (completedTask.IsCompletedSuccessfully)
{
// one of the requests completed successfully; keep its results
// and cancel the remaining http requests.
pluginResults = await completedTask;
cts.Cancel();
var result = await completedTask;
if (result != null)
{
// one of the requests completed successfully; keep its results
// and cancel the remaining http requests.
pluginResults = result;
cts.Cancel();
}
}
tasks.Remove(completedTask);
}

View file

@ -1,19 +1,20 @@
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Forms;
using Flow.Launcher.Core.Resource;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
public abstract class AbstractPluginEnvironment
{
private static readonly string ClassName = nameof(AbstractPluginEnvironment);
protected readonly IPublicAPI API = Ioc.Default.GetRequiredService<IPublicAPI>();
internal abstract string Language { get; }
@ -42,8 +43,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
internal IEnumerable<PluginPair> Setup()
{
// If no plugin is using the language, return empty list
if (!PluginMetadataList.Any(o => o.Language.Equals(Language, StringComparison.OrdinalIgnoreCase)))
{
return new List<PluginPair>();
}
if (!string.IsNullOrEmpty(PluginsSettingsFilePath) && FilesFolders.FileExists(PluginsSettingsFilePath))
{
@ -55,24 +59,55 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
}
var noRuntimeMessage = string.Format(
InternationalizationManager.Instance.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"),
API.GetTranslation("runtimePluginInstalledChooseRuntimePrompt"),
Language,
EnvName,
Environment.NewLine
);
if (API.ShowMsgBox(noRuntimeMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No)
{
var msg = string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName);
string selectedFile;
var msg = string.Format(API.GetTranslation("runtimePluginChooseRuntimeExecutable"), EnvName);
selectedFile = GetFileFromDialog(msg, FileDialogFilter);
var selectedFile = GetFileFromDialog(msg, FileDialogFilter);
if (!string.IsNullOrEmpty(selectedFile))
{
PluginsSettingsFilePath = selectedFile;
}
// Nothing selected because user pressed cancel from the file dialog window
if (string.IsNullOrEmpty(selectedFile))
InstallEnvironment();
else
{
var forceDownloadMessage = string.Format(
API.GetTranslation("runtimeExecutableInvalidChooseDownload"),
Language,
EnvName,
Environment.NewLine
);
// Let users select valid path or choose to download
while (string.IsNullOrEmpty(selectedFile))
{
if (API.ShowMsgBox(forceDownloadMessage, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.Yes)
{
// Continue select file
selectedFile = GetFileFromDialog(msg, FileDialogFilter);
}
else
{
// User selected no, break the loop
break;
}
}
if (!string.IsNullOrEmpty(selectedFile))
{
PluginsSettingsFilePath = selectedFile;
}
else
{
InstallEnvironment();
}
}
}
else
{
@ -85,8 +120,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
}
else
{
API.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language));
Log.Error("PluginsLoader",
API.ShowMsgBox(string.Format(API.GetTranslation("runtimePluginUnableToSetExecutablePath"), Language));
API.LogError(ClassName,
$"Not able to successfully set {EnvName} path, setting's plugin executable path variable is still an empty string.",
$"{Language}Environment");
@ -98,13 +133,11 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
private void EnsureLatestInstalled(string expectedPath, string currentPath, string installedDirPath)
{
if (expectedPath == currentPath)
return;
if (expectedPath == currentPath) return;
FilesFolders.RemoveFolderIfExists(installedDirPath, (s) => API.ShowMsgBox(s));
InstallEnvironment();
}
internal abstract PluginPair CreatePluginPair(string filePath, PluginMetadata metadata);
@ -116,13 +149,16 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
foreach (var metadata in PluginMetadataList)
{
if (metadata.Language.Equals(languageToSet, StringComparison.OrdinalIgnoreCase))
{
metadata.AssemblyName = string.Empty;
pluginPairs.Add(CreatePluginPair(filePath, metadata));
}
}
return pluginPairs;
}
private string GetFileFromDialog(string title, string filter = "")
private static string GetFileFromDialog(string title, string filter = "")
{
var dlg = new OpenFileDialog
{
@ -136,7 +172,6 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
var result = dlg.ShowDialog();
return result == DialogResult.OK ? dlg.FileName : string.Empty;
}
/// <summary>
@ -179,31 +214,33 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
else
{
if (IsUsingPortablePath(settings.PluginSettings.PythonExecutablePath, DataLocation.PythonEnvironmentName))
{
settings.PluginSettings.PythonExecutablePath
= GetUpdatedEnvironmentPath(settings.PluginSettings.PythonExecutablePath);
}
if (IsUsingPortablePath(settings.PluginSettings.NodeExecutablePath, DataLocation.NodeEnvironmentName))
{
settings.PluginSettings.NodeExecutablePath
= GetUpdatedEnvironmentPath(settings.PluginSettings.NodeExecutablePath);
}
}
}
private static bool IsUsingPortablePath(string filePath, string pluginEnvironmentName)
{
if (string.IsNullOrEmpty(filePath))
return false;
if (string.IsNullOrEmpty(filePath)) return false;
// DataLocation.PortableDataPath returns the current portable path, this determines if an out
// of date path is also a portable path.
var portableAppEnvLocation = $"UserData\\{DataLocation.PluginEnvironments}\\{pluginEnvironmentName}";
var portableAppEnvLocation = Path.Combine("UserData", DataLocation.PluginEnvironments, pluginEnvironmentName);
return filePath.Contains(portableAppEnvLocation);
}
private static bool IsUsingRoamingPath(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return false;
if (string.IsNullOrEmpty(filePath)) return false;
return filePath.StartsWith(DataLocation.RoamingDataPath);
}
@ -213,8 +250,8 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
var index = filePath.IndexOf(DataLocation.PluginEnvironments);
// get the substring after "Environments" because we can not determine it dynamically
var ExecutablePathSubstring = filePath.Substring(index + DataLocation.PluginEnvironments.Count());
return $"{DataLocation.PluginEnvironmentsPath}{ExecutablePathSubstring}";
var executablePathSubstring = filePath[(index + DataLocation.PluginEnvironments.Length)..];
return $"{DataLocation.PluginEnvironmentsPath}{executablePathSubstring}";
}
}
}

View file

@ -4,7 +4,6 @@ using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
internal class JavaScriptEnvironment : TypeScriptEnvironment
{
internal override string Language => AllowedLanguage.JavaScript;

View file

@ -4,7 +4,6 @@ using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
internal class JavaScriptV2Environment : TypeScriptV2Environment
{
internal override string Language => AllowedLanguage.JavaScriptV2;

View file

@ -1,10 +1,11 @@
using Droplex;
using System.Collections.Generic;
using System.IO;
using Droplex;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using System.Collections.Generic;
using System.IO;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
@ -22,17 +23,23 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
internal override string FileDialogFilter => "Python|pythonw.exe";
internal override string PluginsSettingsFilePath { get => PluginSettings.PythonExecutablePath; set => PluginSettings.PythonExecutablePath = value; }
internal override string PluginsSettingsFilePath
{
get => PluginSettings.PythonExecutablePath;
set => PluginSettings.PythonExecutablePath = value;
}
internal PythonEnvironment(List<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext());
internal override void InstallEnvironment()
{
FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s));
// Python 3.11.4 is no longer Windows 7 compatible. If user is on Win 7 and
// uses Python plugin they need to custom install and use v3.8.9
DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath).Wait();
JTF.Run(() => DroplexPackage.Drop(App.python_3_11_4_embeddable, InstallPath));
PluginsSettingsFilePath = ExecutablePath;
}

View file

@ -1,10 +1,11 @@
using System.Collections.Generic;
using Droplex;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin.SharedCommands;
using Flow.Launcher.Plugin;
using System.IO;
using Droplex;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0");
internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe");
internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; }
internal override string PluginsSettingsFilePath
{
get => PluginSettings.NodeExecutablePath;
set => PluginSettings.NodeExecutablePath = value;
}
internal TypeScriptEnvironment(List<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext());
internal override void InstallEnvironment()
{
FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s));
DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait();
JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath));
PluginsSettingsFilePath = ExecutablePath;
}

View file

@ -1,10 +1,11 @@
using System.Collections.Generic;
using Droplex;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin.SharedCommands;
using Flow.Launcher.Plugin;
using System.IO;
using Droplex;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher.Core.ExternalPlugins.Environments
{
@ -19,15 +20,21 @@ namespace Flow.Launcher.Core.ExternalPlugins.Environments
internal override string InstallPath => Path.Combine(EnvPath, "Node-v16.18.0");
internal override string ExecutablePath => Path.Combine(InstallPath, "node-v16.18.0-win-x64\\node.exe");
internal override string PluginsSettingsFilePath { get => PluginSettings.NodeExecutablePath; set => PluginSettings.NodeExecutablePath = value; }
internal override string PluginsSettingsFilePath
{
get => PluginSettings.NodeExecutablePath;
set => PluginSettings.NodeExecutablePath = value;
}
internal TypeScriptV2Environment(List<PluginMetadata> pluginMetadataList, PluginsSettings pluginSettings) : base(pluginMetadataList, pluginSettings) { }
private JoinableTaskFactory JTF { get; } = new JoinableTaskFactory(new JoinableTaskContext());
internal override void InstallEnvironment()
{
FilesFolders.RemoveFolderIfExists(InstallPath, (s) => API.ShowMsgBox(s));
DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath).Wait();
JTF.Run(() => DroplexPackage.Drop(App.nodejs_16_18_0, InstallPath));
PluginsSettingsFilePath = ExecutablePath;
}

View file

@ -1,13 +1,16 @@
using Flow.Launcher.Infrastructure.Logger;
using System;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.ExternalPlugins
{
public static class PluginsManifest
{
private static readonly string ClassName = nameof(PluginsManifest);
private static readonly CommunityPluginStore mainPluginStore =
new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json",
"https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json",
@ -17,11 +20,11 @@ namespace Flow.Launcher.Core.ExternalPlugins
private static readonly SemaphoreSlim manifestUpdateLock = new(1);
private static DateTime lastFetchedAt = DateTime.MinValue;
private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan fetchTimeout = TimeSpan.FromMinutes(2);
public static List<UserPlugin> UserPlugins { get; private set; }
public static async Task<bool> UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false)
public static async Task<bool> UpdateManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default)
{
try
{
@ -43,7 +46,7 @@ namespace Flow.Launcher.Core.ExternalPlugins
}
catch (Exception e)
{
Log.Exception($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http request failed", e);
Ioc.Default.GetRequiredService<IPublicAPI>().LogException(ClassName, "Http request failed", e);
}
finally
{

View file

@ -1,23 +0,0 @@
using System;
namespace Flow.Launcher.Core.ExternalPlugins
{
public record UserPlugin
{
public string ID { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Author { get; set; }
public string Version { get; set; }
public string Language { get; set; }
public string Website { get; set; }
public string UrlDownload { get; set; }
public string UrlSourceCode { get; set; }
public string LocalInstallPath { get; set; }
public string IcoPath { get; set; }
public DateTime? LatestReleaseDate { get; set; }
public DateTime? DateAdded { get; set; }
public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath);
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net9.0-windows</TargetFramework>
<UseWpf>true</UseWpf>
<UseWindowsForms>true</UseWindowsForms>
<OutputType>Library</OutputType>
@ -12,6 +12,7 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
@ -57,6 +58,7 @@
<PackageReference Include="FSharp.Core" Version="9.0.201" />
<PackageReference Include="Meziantou.Framework.Win32.Jobs" Version="3.4.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="SemanticVersioning" Version="3.0.0" />
<PackageReference Include="squirrel.windows" Version="1.5.2" NoWarn="NU1701" />
<PackageReference Include="StreamJsonRpc" Version="2.21.10" />
</ItemGroup>

View file

@ -1,28 +1,14 @@
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Infrastructure;
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Plugin;
using Microsoft.IO;
using System.Windows;
using System.Windows.Controls;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using CheckBox = System.Windows.Controls.CheckBox;
using Control = System.Windows.Controls.Control;
using Orientation = System.Windows.Controls.Orientation;
using TextBox = System.Windows.Controls.TextBox;
using UserControl = System.Windows.Controls.UserControl;
using System.Windows.Documents;
namespace Flow.Launcher.Core.Plugin
{
@ -32,7 +18,9 @@ namespace Flow.Launcher.Core.Plugin
/// </summary>
internal abstract class JsonRPCPlugin : JsonRPCPluginBase
{
public const string JsonRPC = "JsonRPC";
public new const string JsonRPC = "JsonRPC";
private static readonly string ClassName = nameof(JsonRPCPlugin);
protected abstract Task<Stream> RequestAsync(JsonRPCRequestModel rpcRequest, CancellationToken token = default);
protected abstract string Request(JsonRPCRequestModel rpcRequest, CancellationToken token = default);
@ -41,9 +29,6 @@ namespace Flow.Launcher.Core.Plugin
private int RequestId { get; set; }
private string SettingConfigurationPath => Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml");
private string SettingPath => Path.Combine(DataLocation.PluginSettingsDirectory, Context.CurrentPluginMetadata.Name, "Settings.json");
public override List<Result> LoadContextMenus(Result selectedResult)
{
var request = new JsonRPCRequestModel(RequestId++,
@ -69,13 +54,6 @@ namespace Flow.Launcher.Core.Plugin
}
};
private static readonly JsonSerializerOptions settingSerializeOption = new()
{
WriteIndented = true
};
private readonly Dictionary<string, FrameworkElement> _settingControls = new();
private async Task<List<Result>> DeserializedResultAsync(Stream output)
{
await using (output)
@ -134,7 +112,6 @@ namespace Flow.Launcher.Core.Plugin
return !result.JsonRPCAction.DontHideAfterAction;
}
/// <summary>
/// Execute external program and return the output
/// </summary>
@ -172,11 +149,11 @@ namespace Flow.Launcher.Core.Plugin
var error = standardError.ReadToEnd();
if (!string.IsNullOrEmpty(error))
{
Log.Error($"|JsonRPCPlugin.Execute|{error}");
Context.API.LogError(ClassName, error);
return string.Empty;
}
Log.Error("|JsonRPCPlugin.Execute|Empty standard output and standard error.");
Context.API.LogError(ClassName, "Empty standard output and standard error.");
return string.Empty;
}
@ -184,8 +161,8 @@ namespace Flow.Launcher.Core.Plugin
}
catch (Exception e)
{
Log.Exception(
$"|JsonRPCPlugin.Execute|Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>",
Context.API.LogException(ClassName,
$"Exception for filename <{startInfo.FileName}> with argument <{startInfo.Arguments}>",
e);
return string.Empty;
}
@ -196,7 +173,7 @@ namespace Flow.Launcher.Core.Plugin
using var process = Process.Start(startInfo);
if (process == null)
{
Log.Error("|JsonRPCPlugin.ExecuteAsync|Can't start new process");
Context.API.LogError(ClassName, "Can't start new process");
return Stream.Null;
}
@ -216,7 +193,7 @@ namespace Flow.Launcher.Core.Plugin
}
catch (Exception e)
{
Log.Exception("|JsonRPCPlugin.ExecuteAsync|Exception when kill process", e);
Context.API.LogException(ClassName, "Exception when kill process", e);
}
});
@ -237,7 +214,7 @@ namespace Flow.Launcher.Core.Plugin
{
case (0, 0):
const string errorMessage = "Empty JSON-RPC Response.";
Log.Warn($"|{nameof(JsonRPCPlugin)}.{nameof(ExecuteAsync)}|{errorMessage}");
Context.API.LogWarn(ClassName, errorMessage);
break;
case (_, not 0):
throw new InvalidDataException(Encoding.UTF8.GetString(errorBuffer.ToArray())); // The process has exited with an error message

View file

@ -1,32 +1,15 @@
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Infrastructure;
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Plugin;
using Microsoft.IO;
using System.Windows;
using System.Windows.Controls;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using CheckBox = System.Windows.Controls.CheckBox;
using Control = System.Windows.Controls.Control;
using Orientation = System.Windows.Controls.Orientation;
using TextBox = System.Windows.Controls.TextBox;
using UserControl = System.Windows.Controls.UserControl;
using System.Windows.Documents;
using static System.Windows.Forms.LinkLabel;
using Droplex;
using System.Windows.Forms;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher.Core.Plugin
{
@ -36,16 +19,14 @@ namespace Flow.Launcher.Core.Plugin
/// </summary>
public abstract class JsonRPCPluginBase : IAsyncPlugin, IContextMenu, ISettingProvider, ISavable
{
protected PluginInitContext Context;
public const string JsonRPC = "JsonRPC";
private int RequestId { get; set; }
protected PluginInitContext Context;
private string SettingConfigurationPath =>
Path.Combine(Context.CurrentPluginMetadata.PluginDirectory, "SettingsTemplate.yaml");
private string SettingDirectory => Path.Combine(DataLocation.PluginSettingsDirectory,
Context.CurrentPluginMetadata.Name);
private string SettingDirectory => Context.CurrentPluginMetadata.PluginSettingsDirectoryPath;
private string SettingPath => Path.Combine(SettingDirectory, "Settings.json");
@ -125,7 +106,6 @@ namespace Flow.Launcher.Core.Plugin
public abstract Task<List<Result>> QueryAsync(Query query, CancellationToken token);
private async Task InitSettingAsync()
{
JsonRpcConfigurationModel configuration = null;
@ -137,7 +117,6 @@ namespace Flow.Launcher.Core.Plugin
await File.ReadAllTextAsync(SettingConfigurationPath));
}
Settings ??= new JsonRPCPluginSettings
{
Configuration = configuration, SettingPath = SettingPath, API = Context.API
@ -148,7 +127,7 @@ namespace Flow.Launcher.Core.Plugin
public virtual async Task InitAsync(PluginInitContext context)
{
this.Context = context;
Context = context;
await InitSettingAsync();
}
@ -166,13 +145,5 @@ namespace Flow.Launcher.Core.Plugin
{
return Settings.CreateSettingPanel();
}
public void DeletePluginSettingsDirectory()
{
if (Directory.Exists(SettingDirectory))
{
Directory.Delete(SettingDirectory, true);
}
}
}
}

View file

@ -1,73 +1,92 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Forms;
using Flow.Launcher.Infrastructure.Storage;
using Flow.Launcher.Plugin;
using CheckBox = System.Windows.Controls.CheckBox;
using ComboBox = System.Windows.Controls.ComboBox;
using Control = System.Windows.Controls.Control;
using Orientation = System.Windows.Controls.Orientation;
using TextBox = System.Windows.Controls.TextBox;
using UserControl = System.Windows.Controls.UserControl;
#nullable enable
namespace Flow.Launcher.Core.Plugin
{
public class JsonRPCPluginSettings
public class JsonRPCPluginSettings : ISavable
{
public required JsonRpcConfigurationModel? Configuration { get; init; }
public required string SettingPath { get; init; }
public Dictionary<string, FrameworkElement> SettingControls { get; } = new();
public IReadOnlyDictionary<string, object> Inner => Settings;
protected ConcurrentDictionary<string, object> Settings { get; set; }
public IReadOnlyDictionary<string, object?> Inner => Settings;
protected ConcurrentDictionary<string, object?> Settings { get; set; } = null!;
public required IPublicAPI API { get; init; }
private JsonStorage<ConcurrentDictionary<string, object>> _storage;
private static readonly string ClassName = nameof(JsonRPCPluginSettings);
// maybe move to resource?
private static readonly Thickness settingControlMargin = new(0, 9, 18, 9);
private static readonly Thickness settingCheckboxMargin = new(0, 9, 9, 9);
private static readonly Thickness settingPanelMargin = new(0, 0, 0, 0);
private static readonly Thickness settingTextBlockMargin = new(70, 9, 18, 9);
private static readonly Thickness settingLabelPanelMargin = new(70, 9, 18, 9);
private static readonly Thickness settingLabelMargin = new(0, 0, 0, 0);
private static readonly Thickness settingDescMargin = new(0, 2, 0, 0);
private static readonly Thickness settingSepMargin = new(0, 0, 0, 2);
private JsonStorage<ConcurrentDictionary<string, object?>> _storage = null!;
private static readonly Thickness SettingPanelMargin = (Thickness)Application.Current.FindResource("SettingPanelMargin");
private static readonly Thickness SettingPanelItemLeftMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftMargin");
private static readonly Thickness SettingPanelItemTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemTopBottomMargin");
private static readonly Thickness SettingPanelItemLeftTopBottomMargin = (Thickness)Application.Current.FindResource("SettingPanelItemLeftTopBottomMargin");
private static readonly double SettingPanelTextBoxMinWidth = (double)Application.Current.FindResource("SettingPanelTextBoxMinWidth");
private static readonly double SettingPanelPathTextBoxWidth = (double)Application.Current.FindResource("SettingPanelPathTextBoxWidth");
private static readonly double SettingPanelAreaTextBoxMinHeight = (double)Application.Current.FindResource("SettingPanelAreaTextBoxMinHeight");
public async Task InitializeAsync()
{
_storage = new JsonStorage<ConcurrentDictionary<string, object>>(SettingPath);
Settings = await _storage.LoadAsync();
if (Configuration == null)
if (Settings == null)
{
return;
_storage = new JsonStorage<ConcurrentDictionary<string, object?>>(SettingPath);
Settings = await _storage.LoadAsync();
// Because value type of settings dictionary is object which causes them to be JsonElement when loading from json files,
// we need to convert it to the correct type
foreach (var (key, value) in Settings)
{
if (value is not JsonElement jsonElement) continue;
Settings[key] = jsonElement.ValueKind switch
{
JsonValueKind.String => jsonElement.GetString() ?? value,
JsonValueKind.True => jsonElement.GetBoolean(),
JsonValueKind.False => jsonElement.GetBoolean(),
JsonValueKind.Null => null,
_ => value
};
}
}
if (Configuration == null) return;
foreach (var (type, attributes) in Configuration.Body)
{
if (attributes.Name == null)
{
continue;
}
// Skip if the setting does not have attributes or name
if (attributes?.Name == null) continue;
if (!Settings.ContainsKey(attributes.Name))
// Skip if the setting does not have attributes or name
if (!NeedSaveInSettings(type)) continue;
// If need save in settings, we need to make sure the setting exists in the settings file
if (Settings.ContainsKey(attributes.Name)) continue;
if (type == "checkbox")
{
// If can parse the default value to bool, use it, otherwise use false
Settings[attributes.Name] = bool.TryParse(attributes.DefaultValue, out var value) && value;
}
else
{
Settings[attributes.Name] = attributes.DefaultValue;
}
}
}
public void UpdateSettings(IReadOnlyDictionary<string, object> settings)
{
if (settings == null || settings.Count == 0)
return;
if (settings == null || settings.Count == 0) return;
foreach (var (key, value) in settings)
{
@ -78,19 +97,23 @@ namespace Flow.Launcher.Core.Plugin
switch (control)
{
case TextBox textBox:
textBox.Dispatcher.Invoke(() => textBox.Text = value as string ?? string.Empty);
var text = value as string ?? string.Empty;
textBox.Dispatcher.Invoke(() => textBox.Text = text);
break;
case PasswordBox passwordBox:
passwordBox.Dispatcher.Invoke(() => passwordBox.Password = value as string ?? string.Empty);
var password = value as string ?? string.Empty;
passwordBox.Dispatcher.Invoke(() => passwordBox.Password = password);
break;
case ComboBox comboBox:
comboBox.Dispatcher.Invoke(() => comboBox.SelectedItem = value);
break;
case CheckBox checkBox:
checkBox.Dispatcher.Invoke(() =>
checkBox.IsChecked = value is bool isChecked
? isChecked
: bool.Parse(value as string ?? string.Empty));
var isChecked = value is bool boolValue
? boolValue
// If can parse the default value to bool, use it, otherwise use false
: value is string stringValue && bool.TryParse(stringValue, out var boolValueFromString)
&& boolValueFromString;
checkBox.Dispatcher.Invoke(() => checkBox.IsChecked = isChecked);
break;
}
}
@ -101,14 +124,28 @@ namespace Flow.Launcher.Core.Plugin
public async Task SaveAsync()
{
await _storage.SaveAsync();
try
{
await _storage.SaveAsync();
}
catch (System.Exception e)
{
API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e);
}
}
public void Save()
{
_storage.Save();
try
{
_storage.Save();
}
catch (System.Exception e)
{
API.LogException(ClassName, $"Failed to save plugin settings to path: {SettingPath}", e);
}
}
public bool NeedCreateSettingPanel()
{
// If there are no settings or the settings configuration is empty, return null
@ -117,330 +154,350 @@ namespace Flow.Launcher.Core.Plugin
public Control CreateSettingPanel()
{
// No need to check if NeedCreateSettingPanel is true because CreateSettingPanel will only be called if it's true
if (!NeedCreateSettingPanel()) return null!;
var settingWindow = new UserControl();
var mainPanel = new Grid { Margin = settingPanelMargin, VerticalAlignment = VerticalAlignment.Center };
ColumnDefinition gridCol1 = new ColumnDefinition();
ColumnDefinition gridCol2 = new ColumnDefinition();
gridCol1.Width = new GridLength(70, GridUnitType.Star);
gridCol2.Width = new GridLength(30, GridUnitType.Star);
mainPanel.ColumnDefinitions.Add(gridCol1);
mainPanel.ColumnDefinitions.Add(gridCol2);
settingWindow.Content = mainPanel;
int rowCount = 0;
foreach (var (type, attribute) in Configuration.Body)
// Create main grid with two columns (Column 1: Auto, Column 2: *)
var mainPanel = new Grid { Margin = SettingPanelMargin, VerticalAlignment = VerticalAlignment.Center };
mainPanel.ColumnDefinitions.Add(new ColumnDefinition()
{
Separator sep = new Separator();
sep.VerticalAlignment = VerticalAlignment.Top;
sep.Margin = settingSepMargin;
sep.SetResourceReference(Separator.BackgroundProperty, "Color03B"); /* for theme change */
var panel = new StackPanel
Width = new GridLength(0, GridUnitType.Auto)
});
mainPanel.ColumnDefinitions.Add(new ColumnDefinition()
{
Width = new GridLength(1, GridUnitType.Star)
});
// Iterate over each setting and create one row for it
var rowCount = 0;
foreach (var (type, attributes) in Configuration!.Body)
{
// Skip if the setting does not have attributes or name
if (attributes?.Name == null) continue;
// Add a new row to the main grid
mainPanel.RowDefinitions.Add(new RowDefinition()
{
Orientation = Orientation.Vertical,
VerticalAlignment = VerticalAlignment.Center,
Margin = settingLabelPanelMargin
};
RowDefinition gridRow = new RowDefinition();
mainPanel.RowDefinitions.Add(gridRow);
var name = new TextBlock()
{
Text = attribute.Label,
VerticalAlignment = VerticalAlignment.Center,
Margin = settingLabelMargin,
TextWrapping = TextWrapping.WrapWithOverflow
};
var desc = new TextBlock()
{
Text = attribute.Description,
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
Margin = settingDescMargin,
TextWrapping = TextWrapping.WrapWithOverflow
};
desc.SetResourceReference(TextBlock.ForegroundProperty, "Color04B");
if (attribute.Description == null) /* if no description, hide */
desc.Visibility = Visibility.Collapsed;
if (type != "textBlock") /* if textBlock, hide desc */
{
panel.Children.Add(name);
panel.Children.Add(desc);
}
Grid.SetColumn(panel, 0);
Grid.SetRow(panel, rowCount);
Height = new GridLength(0, GridUnitType.Auto)
});
// State controls for column 0 and 1
StackPanel? panel = null;
FrameworkElement contentControl;
// If the type is textBlock, separator, or checkbox, we do not need to create a panel
if (type != "textBlock" && type != "separator" && type != "checkbox")
{
// Create a panel to hold the label and description
panel = new StackPanel
{
Margin = SettingPanelItemTopBottomMargin,
Orientation = Orientation.Vertical,
VerticalAlignment = VerticalAlignment.Center
};
// Create a text block for name
var name = new TextBlock()
{
Text = attributes.Label,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.WrapWithOverflow
};
// Create a text block for description
TextBlock? desc = null;
if (attributes.Description != null)
{
desc = new TextBlock()
{
Text = attributes.Description,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.WrapWithOverflow
};
desc.SetResourceReference(TextBlock.StyleProperty, "SettingPanelTextBlockDescriptionStyle"); // for theme change
}
// Add the name and description to the panel
panel.Children.Add(name);
if (desc != null) panel.Children.Add(desc);
}
switch (type)
{
case "textBlock":
{
contentControl = new TextBlock
{
Text = attribute.Description.Replace("\\r\\n", "\r\n"),
Margin = settingTextBlockMargin,
Padding = new Thickness(0, 0, 0, 0),
HorizontalAlignment = System.Windows.HorizontalAlignment.Left,
TextAlignment = TextAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
contentControl = new TextBlock
{
Text = attributes.Description?.Replace("\\r\\n", "\r\n") ?? string.Empty,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemTopBottomMargin,
TextAlignment = TextAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
Grid.SetColumn(contentControl, 0);
Grid.SetColumnSpan(contentControl, 2);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
}
break;
}
case "input":
{
var textBox = new TextBox()
{
Text = Settings[attribute.Name] as string ?? string.Empty,
Margin = settingControlMargin,
HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch,
ToolTip = attribute.Description
};
var textBox = new TextBox()
{
MinWidth = SettingPanelTextBoxMinWidth,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftTopBottomMargin,
Text = Settings[attributes.Name] as string ?? string.Empty,
ToolTip = attributes.Description
};
textBox.TextChanged += (_, _) =>
{
Settings[attribute.Name] = textBox.Text;
};
textBox.TextChanged += (_, _) =>
{
Settings[attributes.Name] = textBox.Text;
};
contentControl = textBox;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
contentControl = textBox;
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
}
break;
}
case "inputWithFileBtn":
case "inputWithFolderBtn":
{
var textBox = new TextBox()
{
Margin = new Thickness(10, 0, 0, 0),
Text = Settings[attribute.Name] as string ?? string.Empty,
HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch,
ToolTip = attribute.Description
};
textBox.TextChanged += (_, _) =>
{
Settings[attribute.Name] = textBox.Text;
};
var Btn = new System.Windows.Controls.Button()
{
Margin = new Thickness(10, 0, 0, 0), Content = "Browse"
};
Btn.Click += (_, _) =>
{
using CommonDialog dialog = type switch
var textBox = new TextBox()
{
"inputWithFolderBtn" => new FolderBrowserDialog(),
_ => new OpenFileDialog(),
Width = SettingPanelPathTextBoxWidth,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftMargin,
Text = Settings[attributes.Name] as string ?? string.Empty,
ToolTip = attributes.Description
};
if (dialog.ShowDialog() != DialogResult.OK) return;
var path = dialog switch
textBox.TextChanged += (_, _) =>
{
FolderBrowserDialog folderDialog => folderDialog.SelectedPath,
OpenFileDialog fileDialog => fileDialog.FileName,
Settings[attributes.Name] = textBox.Text;
};
textBox.Text = path;
Settings[attribute.Name] = path;
};
var dockPanel = new DockPanel() { Margin = settingControlMargin };
var Btn = new Button()
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftMargin,
Content = API.GetTranslation("select")
};
DockPanel.SetDock(Btn, Dock.Right);
dockPanel.Children.Add(Btn);
dockPanel.Children.Add(textBox);
contentControl = dockPanel;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
Btn.Click += (_, _) =>
{
using System.Windows.Forms.CommonDialog dialog = type switch
{
"inputWithFolderBtn" => new System.Windows.Forms.FolderBrowserDialog(),
_ => new System.Windows.Forms.OpenFileDialog(),
};
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
{
return;
}
break;
}
var path = dialog switch
{
System.Windows.Forms.FolderBrowserDialog folderDialog => folderDialog.SelectedPath,
System.Windows.Forms.OpenFileDialog fileDialog => fileDialog.FileName,
_ => throw new System.NotImplementedException()
};
textBox.Text = path;
Settings[attributes.Name] = path;
};
var stackPanel = new StackPanel()
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemTopBottomMargin,
Orientation = Orientation.Horizontal
};
// Create a stack panel to wrap the button and text box
stackPanel.Children.Add(textBox);
stackPanel.Children.Add(Btn);
contentControl = stackPanel;
break;
}
case "textarea":
{
var textBox = new TextBox()
{
Height = 120,
Margin = settingControlMargin,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.WrapWithOverflow,
AcceptsReturn = true,
HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch,
Text = Settings[attribute.Name] as string ?? string.Empty,
ToolTip = attribute.Description
};
var textBox = new TextBox()
{
MinHeight = SettingPanelAreaTextBoxMinHeight,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftTopBottomMargin,
TextWrapping = TextWrapping.WrapWithOverflow,
AcceptsReturn = true,
Text = Settings[attributes.Name] as string ?? string.Empty,
ToolTip = attributes.Description
};
textBox.TextChanged += (sender, _) =>
{
Settings[attribute.Name] = ((TextBox)sender).Text;
};
textBox.TextChanged += (sender, _) =>
{
Settings[attributes.Name] = ((TextBox)sender).Text;
};
contentControl = textBox;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
contentControl = textBox;
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
}
break;
}
case "passwordBox":
{
var passwordBox = new PasswordBox()
{
Margin = settingControlMargin,
Password = Settings[attribute.Name] as string ?? string.Empty,
PasswordChar = attribute.passwordChar == default ? '*' : attribute.passwordChar,
HorizontalAlignment = System.Windows.HorizontalAlignment.Stretch,
ToolTip = attribute.Description
};
var passwordBox = new PasswordBox()
{
MinWidth = SettingPanelTextBoxMinWidth,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftTopBottomMargin,
Password = Settings[attributes.Name] as string ?? string.Empty,
PasswordChar = attributes.passwordChar == default ? '*' : attributes.passwordChar,
ToolTip = attributes.Description,
};
passwordBox.PasswordChanged += (sender, _) =>
{
Settings[attribute.Name] = ((PasswordBox)sender).Password;
};
passwordBox.PasswordChanged += (sender, _) =>
{
Settings[attributes.Name] = ((PasswordBox)sender).Password;
};
contentControl = passwordBox;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
contentControl = passwordBox;
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
}
break;
}
case "dropdown":
{
var comboBox = new System.Windows.Controls.ComboBox()
{
ItemsSource = attribute.Options,
SelectedItem = Settings[attribute.Name],
Margin = settingControlMargin,
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
ToolTip = attribute.Description
};
var comboBox = new ComboBox()
{
ItemsSource = attributes.Options,
SelectedItem = Settings[attributes.Name],
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftTopBottomMargin,
ToolTip = attributes.Description
};
comboBox.SelectionChanged += (sender, _) =>
{
Settings[attribute.Name] = (string)((System.Windows.Controls.ComboBox)sender).SelectedItem;
};
comboBox.SelectionChanged += (sender, _) =>
{
Settings[attributes.Name] = (string)((ComboBox)sender).SelectedItem;
};
contentControl = comboBox;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
contentControl = comboBox;
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
}
break;
}
case "checkbox":
var checkBox = new CheckBox
{
IsChecked =
Settings[attribute.Name] is bool isChecked
// If can parse the default value to bool, use it, otherwise use false
var defaultValue = bool.TryParse(attributes.DefaultValue, out var value) && value;
var checkBox = new CheckBox
{
IsChecked =
Settings[attributes.Name] is bool isChecked
? isChecked
: bool.Parse(attribute.DefaultValue),
Margin = settingCheckboxMargin,
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
ToolTip = attribute.Description
};
: defaultValue,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemTopBottomMargin,
Content = attributes.Label,
ToolTip = attributes.Description
};
checkBox.Click += (sender, _) =>
{
Settings[attribute.Name] = ((CheckBox)sender).IsChecked;
};
checkBox.Click += (sender, _) =>
{
Settings[attributes.Name] = ((CheckBox)sender).IsChecked ?? defaultValue;
};
contentControl = checkBox;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
contentControl = checkBox;
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
break;
break;
}
case "hyperlink":
var hyperlink = new Hyperlink { ToolTip = attribute.Description, NavigateUri = attribute.url };
var linkbtn = new System.Windows.Controls.Button
{
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
Margin = settingControlMargin
};
var hyperlink = new Hyperlink
{
ToolTip = attributes.Description,
NavigateUri = attributes.url
};
linkbtn.Content = attribute.urlLabel;
hyperlink.Inlines.Add(attributes.urlLabel);
hyperlink.RequestNavigate += (sender, e) =>
{
API.OpenUrl(e.Uri);
e.Handled = true;
};
contentControl = linkbtn;
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
if (rowCount != 0)
mainPanel.Children.Add(sep);
var textBlock = new TextBlock()
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Margin = SettingPanelItemLeftTopBottomMargin,
TextAlignment = TextAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
textBlock.Inlines.Add(hyperlink);
Grid.SetRow(sep, rowCount);
Grid.SetColumn(sep, 0);
Grid.SetColumnSpan(sep, 2);
contentControl = textBlock;
break;
break;
}
case "separator":
{
var sep = new Separator();
sep.SetResourceReference(Separator.StyleProperty, "SettingPanelSeparatorStyle");
contentControl = sep;
break;
}
default:
continue;
}
if (type != "textBlock")
SettingControls[attribute.Name] = contentControl;
// If type is textBlock or separator, we just add the content control to the main grid
if (panel == null)
{
// Add the content control to the column 0, row rowCount and columnSpan 2 of the main grid
mainPanel.Children.Add(contentControl);
Grid.SetColumn(contentControl, 0);
Grid.SetColumnSpan(contentControl, 2);
Grid.SetRow(contentControl, rowCount);
}
else
{
// Add the panel to the column 0 and row rowCount of the main grid
mainPanel.Children.Add(panel);
Grid.SetColumn(panel, 0);
Grid.SetRow(panel, rowCount);
// Add the content control to the column 1 and row rowCount of the main grid
mainPanel.Children.Add(contentControl);
Grid.SetColumn(contentControl, 1);
Grid.SetRow(contentControl, rowCount);
}
// Add into SettingControls for settings storage if need
if (NeedSaveInSettings(type)) SettingControls[attributes.Name] = contentControl;
mainPanel.Children.Add(panel);
mainPanel.Children.Add(contentControl);
rowCount++;
}
return settingWindow;
// Wrap the main grid in a user control
return new UserControl()
{
Content = mainPanel
};
}
private static bool NeedSaveInSettings(string type)
{
return type != "textBlock" && type != "separator" && type != "hyperlink";
}
}
}

View file

@ -10,20 +10,20 @@ using Microsoft.VisualStudio.Threading;
using StreamJsonRpc;
using IAsyncDisposable = System.IAsyncDisposable;
namespace Flow.Launcher.Core.Plugin
{
internal abstract class JsonRPCPluginV2 : JsonRPCPluginBase, IAsyncDisposable, IAsyncReloadable, IResultUpdated
{
public const string JsonRpc = "JsonRPC";
private static readonly string ClassName = nameof(JsonRPCPluginV2);
protected abstract IDuplexPipe ClientPipe { get; set; }
protected StreamReader ErrorStream { get; set; }
private JsonRpc RPC { get; set; }
protected override async Task<bool> ExecuteResultAsync(JsonRPCResult result)
{
var res = await RPC.InvokeAsync<JsonRPCExecuteResponse>(result.JsonRPCAction.Method,
@ -55,7 +55,6 @@ namespace Flow.Launcher.Core.Plugin
return results;
}
public override async Task InitAsync(PluginInitContext context)
{
await base.InitAsync(context);
@ -88,7 +87,6 @@ namespace Flow.Launcher.Core.Plugin
protected abstract MessageHandlerType MessageHandler { get; }
private void SetupJsonRPC()
{
var formatter = new SystemTextJsonFormatter { JsonSerializerOptions = RequestSerializeOption };
@ -118,8 +116,17 @@ namespace Flow.Launcher.Core.Plugin
{
await RPC.InvokeAsync("reload_data", Context);
}
catch (RemoteMethodNotFoundException e)
catch (RemoteMethodNotFoundException)
{
// Ignored
}
catch (ConnectionLostException)
{
// Ignored
}
catch (Exception e)
{
Context.API.LogException(ClassName, $"Failed to call reload_data for plugin {Context.CurrentPluginMetadata.Name}", e);
}
}
@ -129,8 +136,17 @@ namespace Flow.Launcher.Core.Plugin
{
await RPC.InvokeAsync("close");
}
catch (RemoteMethodNotFoundException e)
catch (RemoteMethodNotFoundException)
{
// Ignored
}
catch (ConnectionLostException)
{
// Ignored
}
catch (Exception e)
{
Context.API.LogException(ClassName, $"Failed to call close for plugin {Context.CurrentPluginMetadata.Name}", e);
}
finally
{

View file

@ -12,7 +12,7 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
{
public class JsonRPCPublicAPI
{
private IPublicAPI _api;
private readonly IPublicAPI _api;
public JsonRPCPublicAPI(IPublicAPI api)
{
@ -104,7 +104,6 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
return _api.GetAllPlugins();
}
public MatchResult FuzzySearch(string query, string stringToCompare)
{
return _api.FuzzySearch(query, stringToCompare);
@ -156,6 +155,11 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
_api.LogWarn(className, message, methodName);
}
public void LogError(string className, string message, [CallerMemberName] string methodName = "")
{
_api.LogError(className, message, methodName);
}
public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null)
{
_api.OpenDirectory(DirectoryPath, FileNameOrFilePath);
@ -185,5 +189,10 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models
{
_api.StopLoadingBar();
}
public void SavePluginCaches()
{
_api.SavePluginCaches();
}
}
}

View file

@ -1,17 +1,22 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Plugin;
using System.Text.Json;
using CommunityToolkit.Mvvm.DependencyInjection;
namespace Flow.Launcher.Core.Plugin
{
internal abstract class PluginConfig
{
private static readonly string ClassName = nameof(PluginConfig);
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
/// <summary>
/// Parse plugin metadata in the given directories
/// </summary>
@ -33,7 +38,7 @@ namespace Flow.Launcher.Core.Plugin
}
catch (Exception e)
{
Log.Exception($"|PluginConfig.ParsePLuginConfigs|Can't delete <{directory}>", e);
API.LogException(ClassName, $"Can't delete <{directory}>", e);
}
}
else
@ -50,11 +55,11 @@ namespace Flow.Launcher.Core.Plugin
duplicateList
.ForEach(
x => Log.Warn("PluginConfig",
string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " +
"not loaded due to version not the highest of the duplicates",
x.Name, x.ID, x.Version),
"GetUniqueLatestPluginMetadata"));
x => API.LogWarn(ClassName,
string.Format("Duplicate plugin name: {0}, id: {1}, version: {2} " +
"not loaded due to version not the highest of the duplicates",
x.Name, x.ID, x.Version),
"GetUniqueLatestPluginMetadata"));
return uniqueList;
}
@ -102,7 +107,7 @@ namespace Flow.Launcher.Core.Plugin
string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName);
if (!File.Exists(configPath))
{
Log.Error($"|PluginConfig.GetPluginMetadata|Didn't find config file <{configPath}>");
API.LogError(ClassName, $"Didn't find config file <{configPath}>");
return null;
}
@ -112,29 +117,29 @@ namespace Flow.Launcher.Core.Plugin
metadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(configPath));
metadata.PluginDirectory = pluginDirectory;
// for plugins which doesn't has ActionKeywords key
metadata.ActionKeywords = metadata.ActionKeywords ?? new List<string> { metadata.ActionKeyword };
metadata.ActionKeywords ??= new List<string> { metadata.ActionKeyword };
// for plugin still use old ActionKeyword
metadata.ActionKeyword = metadata.ActionKeywords?[0];
}
catch (Exception e)
{
Log.Exception($"|PluginConfig.GetPluginMetadata|invalid json for config <{configPath}>", e);
API.LogException(ClassName, $"Invalid json for config <{configPath}>", e);
return null;
}
if (!AllowedLanguage.IsAllowed(metadata.Language))
{
Log.Error($"|PluginConfig.GetPluginMetadata|Invalid language <{metadata.Language}> for config <{configPath}>");
API.LogError(ClassName, $"Invalid language <{metadata.Language}> for config <{configPath}>");
return null;
}
if (!File.Exists(metadata.ExecuteFilePath))
{
Log.Error($"|PluginConfig.GetPluginMetadata|execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}");
API.LogError(ClassName, $"Execute file path didn't exist <{metadata.ExecuteFilePath}> for conifg <{configPath}");
return null;
}
return metadata;
}
}
}
}

View file

@ -0,0 +1,482 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin;
/// <summary>
/// Class for installing, updating, and uninstalling plugins.
/// </summary>
public static class PluginInstaller
{
private static readonly string ClassName = nameof(PluginInstaller);
private static readonly Settings Settings = Ioc.Default.GetRequiredService<Settings>();
// 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<IPublicAPI>();
/// <summary>
/// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The plugin to install.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin)
{
if (API.PluginModified(newPlugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), newPlugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("InstallPromptSubtitle"),
newPlugin.Name, newPlugin.Author, Environment.NewLine),
API.GetTranslation("InstallPromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
// at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download
var downloadFilename = string.IsNullOrEmpty(newPlugin.Version)
? $"{newPlugin.Name}-{Guid.NewGuid()}.zip"
: $"{newPlugin.Name}-{newPlugin.Version}.zip";
var filePath = Path.Combine(Path.GetTempPath(), downloadFilename);
using var cts = new CancellationTokenSource();
if (!newPlugin.IsFromLocalInstallPath)
{
await DownloadFileAsync(
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
newPlugin.UrlDownload, filePath, cts);
}
else
{
filePath = newPlugin.LocalInstallPath;
}
// check if user cancelled download before installing plugin
if (cts.IsCancellationRequested)
{
return;
}
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath);
}
if (!API.InstallPlugin(newPlugin, filePath))
{
return;
}
if (!newPlugin.IsFromLocalInstallPath)
{
File.Delete(filePath);
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to install plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin"));
return; // do not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("installbtn"),
string.Format(
API.GetTranslation(
"InstallSuccessNoRestart"),
newPlugin.Name));
}
}
/// <summary>
/// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation.
/// </summary>
/// <param name="filePath">The path to the plugin zip file.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
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<UserPlugin>(stream);
plugin.IcoPath = "Images\\zipfolder.png";
plugin.LocalInstallPath = filePath;
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to validate zip file", e);
API.ShowMsgError(API.GetTranslation("ZipFileNotHavePluginJson"));
return;
}
if (API.PluginModified(plugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
if (Settings.ShowUnknownSourceWarning)
{
if (!InstallSourceKnown(plugin.Website)
&& API.ShowMsgBox(string.Format(
API.GetTranslation("InstallFromUnknownSourceSubtitle"), Environment.NewLine),
API.GetTranslation("InstallFromUnknownSourceTitle"),
MessageBoxButton.YesNo) == MessageBoxResult.No)
return;
}
await InstallPluginAndCheckRestartAsync(plugin);
}
/// <summary>
/// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings.
/// </summary>
/// <param name="oldPlugin">The plugin metadata to uninstall.</param>
/// <returns>A Task representing the asynchronous uninstall operation.</returns>
public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin)
{
if (API.PluginModified(oldPlugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), oldPlugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("UninstallPromptSubtitle"),
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
API.GetTranslation("UninstallPromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
var removePluginSettings = API.ShowMsgBox(
API.GetTranslation("KeepPluginSettingsSubtitle"),
API.GetTranslation("KeepPluginSettingsTitle"),
button: MessageBoxButton.YesNo) == MessageBoxResult.No;
try
{
if (!await API.UninstallPluginAsync(oldPlugin, removePluginSettings))
{
return;
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to uninstall plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin"));
return; // don not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("uninstallbtn"),
string.Format(
API.GetTranslation(
"UninstallSuccessNoRestart"),
oldPlugin.Name));
}
}
/// <summary>
/// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The new plugin version to install.</param>
/// <param name="oldPlugin">The existing plugin metadata to update.</param>
/// <returns>A Task representing the asynchronous update operation.</returns>
public static async Task UpdatePluginAndCheckRestartAsync(UserPlugin newPlugin, PluginMetadata oldPlugin)
{
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("UpdatePromptSubtitle"),
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
API.GetTranslation("UpdatePromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
var filePath = Path.Combine(Path.GetTempPath(), $"{newPlugin.Name}-{newPlugin.Version}.zip");
using var cts = new CancellationTokenSource();
if (!newPlugin.IsFromLocalInstallPath)
{
await DownloadFileAsync(
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
newPlugin.UrlDownload, filePath, cts);
}
else
{
filePath = newPlugin.LocalInstallPath;
}
// check if user cancelled download before installing plugin
if (cts.IsCancellationRequested)
{
return;
}
if (!await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath))
{
return;
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to update plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin"));
return; // do not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("updatebtn"),
string.Format(
API.GetTranslation(
"UpdateSuccessNoRestart"),
newPlugin.Name));
}
}
/// <summary>
/// Updates the plugin to the latest version available from its source.
/// </summary>
/// <param name="updateAllPlugins">Action to execute when the user chooses to update all plugins.</param>
/// <param name="silentUpdate">If true, do not show any messages when there is no update available.</param>
/// <param name="usePrimaryUrlOnly">If true, only use the primary URL for updates.</param>
/// <param name="token">Cancellation token to cancel the update operation.</param>
/// <returns></returns>
public static async Task CheckForPluginUpdatesAsync(Action<List<PluginUpdateInfo>> updateAllPlugins, bool silentUpdate = true, bool usePrimaryUrlOnly = false, CancellationToken token = default)
{
// Update the plugin manifest
await API.UpdatePluginManifestAsync(usePrimaryUrlOnly, token);
// Get all plugins that can be updated
var resultsForUpdate = (
from existingPlugin in API.GetAllPlugins()
join pluginUpdateSource in API.GetPluginManifest()
on existingPlugin.Metadata.ID equals pluginUpdateSource.ID
where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version,
StringComparison.InvariantCulture) <
0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest)
&& !API.PluginModified(existingPlugin.Metadata.ID)
select
new PluginUpdateInfo()
{
ID = existingPlugin.Metadata.ID,
Name = existingPlugin.Metadata.Name,
Author = existingPlugin.Metadata.Author,
CurrentVersion = existingPlugin.Metadata.Version,
NewVersion = pluginUpdateSource.Version,
IcoPath = existingPlugin.Metadata.IcoPath,
PluginExistingMetadata = existingPlugin.Metadata,
PluginNewUserPlugin = pluginUpdateSource
}).ToList();
// No updates
if (!resultsForUpdate.Any())
{
if (!silentUpdate)
{
API.ShowMsg(API.GetTranslation("updateNoResultTitle"), API.GetTranslation("updateNoResultSubtitle"));
}
return;
}
// If all plugins are modified, just return
if (resultsForUpdate.All(x => API.PluginModified(x.ID)))
{
return;
}
// Show message box with button to update all plugins
API.ShowMsgWithButton(
API.GetTranslation("updateAllPluginsTitle"),
API.GetTranslation("updateAllPluginsButtonContent"),
() =>
{
updateAllPlugins(resultsForUpdate);
},
string.Join(", ", resultsForUpdate.Select(x => x.PluginExistingMetadata.Name)));
}
/// <summary>
/// Updates all plugins that have available updates.
/// </summary>
/// <param name="resultsForUpdate"></param>
/// <param name="restart"></param>
public static async Task UpdateAllPluginsAsync(IEnumerable<PluginUpdateInfo> resultsForUpdate, bool restart)
{
var anyPluginSuccess = false;
await Task.WhenAll(resultsForUpdate.Select(async plugin =>
{
var downloadToFilePath = Path.Combine(Path.GetTempPath(), $"{plugin.Name}-{plugin.NewVersion}.zip");
try
{
using var cts = new CancellationTokenSource();
await DownloadFileAsync(
$"{API.GetTranslation("DownloadingPlugin")} {plugin.PluginNewUserPlugin.Name}",
plugin.PluginNewUserPlugin.UrlDownload, downloadToFilePath, cts);
// check if user cancelled download before installing plugin
if (cts.IsCancellationRequested)
{
return;
}
if (!await API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, downloadToFilePath))
{
return;
}
anyPluginSuccess = true;
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to update plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin"));
}
}));
if (!anyPluginSuccess) return;
if (restart)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("updatebtn"),
API.GetTranslation("PluginsUpdateSuccessNoRestart"));
}
}
/// <summary>
/// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation.
/// </summary>
/// <param name="progressBoxTitle">The title for the progress box.</param>
/// <param name="downloadUrl">The URL to download from.</param>
/// <param name="filePath">The local file path to save to.</param>
/// <param name="cts">Cancellation token source for cancelling the download.</param>
/// <param name="deleteFile">Whether to delete the file if it already exists.</param>
/// <param name="showProgress">Whether to show a progress box during download.</param>
/// <returns>A Task representing the asynchronous download operation.</returns>
private static async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true)
{
if (deleteFile && File.Exists(filePath))
File.Delete(filePath);
if (showProgress)
{
var exceptionHappened = false;
await API.ShowProgressBoxAsync(progressBoxTitle,
async (reportProgress) =>
{
if (reportProgress == null)
{
// when reportProgress is null, it means there is exception with the progress box
// so we record it with exceptionHappened and return so that progress box will close instantly
exceptionHappened = true;
return;
}
else
{
await API.HttpDownloadAsync(downloadUrl, filePath, reportProgress, cts.Token).ConfigureAwait(false);
}
}, cts.Cancel);
// if exception happened while downloading and user does not cancel downloading,
// we need to redownload the plugin
if (exceptionHappened && (!cts.IsCancellationRequested))
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
}
else
{
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author).
/// </summary>
/// <param name="url">The URL to check.</param>
/// <returns>True if the source is known, otherwise false.</returns>
private static bool InstallSourceKnown(string url)
{
if (string.IsNullOrEmpty(url))
return false;
var pieces = url.Split('/');
if (pieces.Length < 4)
return false;
var author = pieces[3];
var acceptedHost = "github.com";
var acceptedSource = "https://github.com";
var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost)
return false;
return API.GetAllPlugins().Any(x =>
!string.IsNullOrEmpty(x.Metadata.Website) &&
x.Metadata.Website.StartsWith(constructedUrlPart)
);
}
}
public record PluginUpdateInfo
{
public string ID { get; init; }
public string Name { get; init; }
public string Author { get; init; }
public string CurrentVersion { get; init; }
public string NewVersion { get; init; }
public string IcoPath { get; init; }
public PluginMetadata PluginExistingMetadata { get; init; }
public UserPlugin PluginNewUserPlugin { get; init; }
}

View file

@ -1,29 +1,28 @@
using Flow.Launcher.Core.ExternalPlugins;
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Core.ExternalPlugins;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using ISavable = Flow.Launcher.Plugin.ISavable;
using Flow.Launcher.Plugin.SharedCommands;
using System.Text.Json;
using Flow.Launcher.Core.Resource;
using CommunityToolkit.Mvvm.DependencyInjection;
using IRemovable = Flow.Launcher.Core.Storage.IRemovable;
using ISavable = Flow.Launcher.Plugin.ISavable;
namespace Flow.Launcher.Core.Plugin
{
/// <summary>
/// The entry for managing Flow Launcher plugins
/// Class for co-ordinating and managing all plugin lifecycle.
/// </summary>
public static class PluginManager
{
private static IEnumerable<PluginPair> _contextMenuPlugins;
private static readonly string ClassName = nameof(PluginManager);
public static List<PluginPair> AllPlugins { get; private set; }
public static readonly HashSet<PluginPair> GlobalPlugins = new();
@ -34,8 +33,12 @@ namespace Flow.Launcher.Core.Plugin
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
private static PluginsSettings Settings;
private static List<PluginMetadata> _metadatas;
private static List<string> _modifiedPlugins = new List<string>();
private static readonly ConcurrentBag<string> ModifiedPlugins = new();
private static IEnumerable<PluginPair> _contextMenuPlugins;
private static IEnumerable<PluginPair> _homePlugins;
private static IEnumerable<PluginPair> _resultUpdatePlugin;
private static IEnumerable<PluginPair> _translationPlugins;
/// <summary>
/// Directories that will hold Flow Launcher plugin directory
@ -59,18 +62,34 @@ namespace Flow.Launcher.Core.Plugin
/// </summary>
public static void Save()
{
foreach (var plugin in AllPlugins)
foreach (var pluginPair in AllPlugins)
{
var savable = plugin.Plugin as ISavable;
savable?.Save();
var savable = pluginPair.Plugin as ISavable;
try
{
savable?.Save();
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e);
}
}
API.SavePluginSettings();
API.SavePluginCaches();
}
public static async ValueTask DisposePluginsAsync()
{
foreach (var pluginPair in AllPlugins)
{
await DisposePluginAsync(pluginPair);
}
}
private static async Task DisposePluginAsync(PluginPair pluginPair)
{
try
{
switch (pluginPair.Plugin)
{
@ -82,6 +101,10 @@ namespace Flow.Launcher.Core.Plugin
break;
}
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e);
}
}
public static async Task ReloadDataAsync()
@ -151,10 +174,45 @@ namespace Flow.Launcher.Core.Plugin
/// <param name="settings"></param>
public static void LoadPlugins(PluginsSettings settings)
{
_metadatas = PluginConfig.Parse(Directories);
var metadatas = PluginConfig.Parse(Directories);
Settings = settings;
Settings.UpdatePluginSettings(_metadatas);
AllPlugins = PluginsLoader.Plugins(_metadatas, Settings);
Settings.UpdatePluginSettings(metadatas);
AllPlugins = PluginsLoader.Plugins(metadatas, Settings);
// Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins
UpdatePluginDirectory(metadatas);
// Initialize plugin enumerable after all plugins are initialized
_contextMenuPlugins = GetPluginsForInterface<IContextMenu>();
_homePlugins = GetPluginsForInterface<IAsyncHomeQuery>();
_resultUpdatePlugin = GetPluginsForInterface<IResultUpdated>();
_translationPlugins = GetPluginsForInterface<IPluginI18n>();
}
private static void UpdatePluginDirectory(List<PluginMetadata> metadatas)
{
foreach (var metadata in metadatas)
{
if (AllowedLanguage.IsDotNet(metadata.Language))
{
if (string.IsNullOrEmpty(metadata.AssemblyName))
{
API.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}");
continue; // Skip if AssemblyName is not set, which can happen for erroneous plugins
}
metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.AssemblyName);
metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.AssemblyName);
}
else
{
if (string.IsNullOrEmpty(metadata.Name))
{
API.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}");
continue; // Skip if Name is not set, which can happen for erroneous plugins
}
metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.Name);
metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.Name);
}
}
}
/// <summary>
@ -169,24 +227,34 @@ namespace Flow.Launcher.Core.Plugin
{
try
{
var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.InitializePlugins|Init method time cost for <{pair.Metadata.Name}>",
var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>",
() => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API)));
pair.Metadata.InitTime += milliseconds;
Log.Info(
$"|PluginManager.InitializePlugins|Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>");
API.LogInfo(ClassName,
$"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>");
}
catch (Exception e)
{
Log.Exception(nameof(PluginManager), $"Fail to Init plugin: {pair.Metadata.Name}", e);
pair.Metadata.Disabled = true;
failedPlugins.Enqueue(pair);
API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e);
if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled)
{
// If this plugin is already disabled, do not show error message again
// Or else it will be shown every time
API.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error");
}
else
{
pair.Metadata.Disabled = true;
pair.Metadata.HomeDisabled = true;
failedPlugins.Enqueue(pair);
API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed");
}
}
}));
await Task.WhenAll(InitTasks);
_contextMenuPlugins = GetPluginsForInterface<IContextMenu>();
foreach (var plugin in AllPlugins)
{
// set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin
@ -205,9 +273,6 @@ namespace Flow.Launcher.Core.Plugin
}
}
InternationalizationManager.Instance.AddPluginLanguageDirectories(GetPluginsForInterface<IPluginI18n>());
InternationalizationManager.Instance.ChangeLanguage(Ioc.Default.GetRequiredService<Settings>().Language);
if (failedPlugins.Any())
{
var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name));
@ -228,17 +293,27 @@ namespace Flow.Launcher.Core.Plugin
if (query is null)
return Array.Empty<PluginPair>();
if (!NonGlobalPlugins.ContainsKey(query.ActionKeyword))
return GlobalPlugins;
if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin))
{
return GlobalPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList();
}
if (API.PluginModified(plugin.Metadata.ID))
{
return Array.Empty<PluginPair>();
}
var plugin = NonGlobalPlugins[query.ActionKeyword];
return new List<PluginPair>
{
plugin
};
}
public static ICollection<PluginPair> ValidPluginsForHomeQuery()
{
return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList();
}
public static async Task<List<Result>> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
@ -246,7 +321,7 @@ namespace Flow.Launcher.Core.Plugin
try
{
var milliseconds = await Stopwatch.DebugAsync($"|PluginManager.QueryForPlugin|Cost for {metadata.Name}",
var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
@ -270,7 +345,7 @@ namespace Flow.Launcher.Core.Plugin
{
Title = $"{metadata.Name}: Failed to respond!",
SubTitle = "Select this result for more info",
IcoPath = Flow.Launcher.Infrastructure.Constant.ErrorIcon,
IcoPath = Constant.ErrorIcon,
PluginDirectory = metadata.PluginDirectory,
ActionKeywordAssigned = query.ActionKeyword,
PluginID = metadata.ID,
@ -283,6 +358,36 @@ namespace Flow.Launcher.Core.Plugin
return results;
}
public static async Task<List<Result>> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
var metadata = pair.Metadata;
try
{
var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
if (results == null)
return null;
UpdatePluginMetadata(results, metadata, query);
token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e);
return null;
}
return results;
}
public static void UpdatePluginMetadata(IReadOnlyList<Result> results, PluginMetadata metadata, Query query)
{
foreach (var r in results)
@ -308,16 +413,26 @@ namespace Flow.Launcher.Core.Plugin
return AllPlugins.FirstOrDefault(o => o.Metadata.ID == id);
}
public static IEnumerable<PluginPair> GetPluginsForInterface<T>() where T : IFeatures
private static IEnumerable<PluginPair> GetPluginsForInterface<T>() 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<PluginPair>();
}
public static IList<PluginPair> GetResultUpdatePlugin()
{
return _resultUpdatePlugin.Where(p => !PluginModified(p.Metadata.ID)).ToList();
}
public static IList<PluginPair> GetTranslationPlugins()
{
return _translationPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList();
}
public static List<Result> GetContextMenusForPlugin(Result result)
{
var results = new List<Result>();
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;
@ -334,8 +449,8 @@ namespace Flow.Launcher.Core.Plugin
}
catch (Exception e)
{
Log.Exception(
$"|PluginManager.GetContextMenusForPlugin|Can't load context menus for plugin <{pluginPair.Metadata.Name}>",
API.LogException(ClassName,
$"Can't load context menus for plugin <{pluginPair.Metadata.Name}>",
e);
}
}
@ -343,12 +458,17 @@ namespace Flow.Launcher.Core.Plugin
return results;
}
public static bool IsHomePlugin(string id)
{
return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id);
}
public static bool ActionKeywordRegistered(string actionKeyword)
{
// this method is only checking for action keywords (defined as not '*') registration
// hence the actionKeyword != Query.GlobalPluginWildcardSign logic
return actionKeyword != Query.GlobalPluginWildcardSign
&& NonGlobalPlugins.ContainsKey(actionKeyword);
return actionKeyword != Query.GlobalPluginWildcardSign
&& NonGlobalPlugins.ContainsKey(actionKeyword);
}
/// <summary>
@ -367,7 +487,16 @@ namespace Flow.Launcher.Core.Plugin
NonGlobalPlugins[newActionKeyword] = plugin;
}
// Update action keywords and action keyword in plugin metadata
plugin.Metadata.ActionKeywords.Add(newActionKeyword);
if (plugin.Metadata.ActionKeywords.Count > 0)
{
plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0];
}
else
{
plugin.Metadata.ActionKeyword = string.Empty;
}
}
/// <summary>
@ -388,16 +517,15 @@ namespace Flow.Launcher.Core.Plugin
if (oldActionkeyword != Query.GlobalPluginWildcardSign)
NonGlobalPlugins.Remove(oldActionkeyword);
// Update action keywords and action keyword in plugin metadata
plugin.Metadata.ActionKeywords.Remove(oldActionkeyword);
}
public static void ReplaceActionKeyword(string id, string oldActionKeyword, string newActionKeyword)
{
if (oldActionKeyword != newActionKeyword)
if (plugin.Metadata.ActionKeywords.Count > 0)
{
AddActionKeyword(id, newActionKeyword);
RemoveActionKeyword(id, oldActionKeyword);
plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0];
}
else
{
plugin.Metadata.ActionKeyword = string.Empty;
}
}
@ -422,55 +550,62 @@ namespace Flow.Launcher.Core.Plugin
private static bool SameOrLesserPluginVersionExists(string metadataPath)
{
var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath));
if (!Version.TryParse(newMetadata.Version, out var newVersion))
return true; // If version is not valid, we assume it is lesser than any existing version
return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID
&& newMetadata.Version.CompareTo(x.Metadata.Version) <= 0);
&& Version.TryParse(x.Metadata.Version, out var version)
&& newVersion <= version);
}
#region Public functions
public static bool PluginModified(string uuid)
public static bool PluginModified(string id)
{
return _modifiedPlugins.Contains(uuid);
return ModifiedPlugins.Contains(id);
}
/// <summary>
/// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url,
/// unless it's a local path installation
/// </summary>
public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
public static async Task<bool> UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
{
InstallPlugin(newVersion, zipFilePath, checkModified:false);
UninstallPlugin(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false);
_modifiedPlugins.Add(existingVersion.ID);
if (PluginModified(existingVersion.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), existingVersion.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return false;
}
var installSuccess = InstallPlugin(newVersion, zipFilePath, checkModified: false);
if (!installSuccess) return false;
var uninstallSuccess = await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false);
if (!uninstallSuccess) return false;
ModifiedPlugins.Add(existingVersion.ID);
return true;
}
/// <summary>
/// Install a plugin. By default will remove the zip file if installation is from url, unless it's a local path installation
/// </summary>
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);
}
/// <summary>
/// Uninstall a plugin.
/// </summary>
public static void UninstallPlugin(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false)
public static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false)
{
UninstallPlugin(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));
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return false;
}
// Unzip plugin files to temp folder
@ -488,31 +623,35 @@ namespace Flow.Launcher.Core.Plugin
if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath))
{
throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist");
API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name),
string.Format(API.GetTranslation("fileNotFoundMessage"), pluginFolderPath));
return false;
}
if (SameOrLesserPluginVersionExists(metadataJsonFilePath))
{
throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}");
API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name),
API.GetTranslation("pluginExistAlreadyMessage"));
return false;
}
var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}";
var defaultPluginIDs = new List<string>
{
"0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark
"CEA0FDFC6D3B4085823D60DC76F28855", // Calculator
"572be03c74c642baae319fc283e561a8", // Explorer
"6A122269676E40EB86EB543B945932B9", // PluginIndicator
"9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager
"b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller
"791FC278BA414111B8D1886DFE447410", // Program
"D409510CD0D2481F853690A07E6DC426", // Shell
"CEA08895D2544B019B2E9C5009600DF4", // Sys
"0308FD86DE0A4DEE8D62B9B535370992", // URL
"565B73353DBF4806919830B9202EE3BF", // WebSearch
"5043CETYU6A748679OPA02D27D99677A" // WindowsSettings
};
{
"0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark
"CEA0FDFC6D3B4085823D60DC76F28855", // Calculator
"572be03c74c642baae319fc283e561a8", // Explorer
"6A122269676E40EB86EB543B945932B9", // PluginIndicator
"9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager
"b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller
"791FC278BA414111B8D1886DFE447410", // Program
"D409510CD0D2481F853690A07E6DC426", // Shell
"CEA08895D2544B019B2E9C5009600DF4", // Sys
"0308FD86DE0A4DEE8D62B9B535370992", // URL
"565B73353DBF4806919830B9202EE3BF", // WebSearch
"5043CETYU6A748679OPA02D27D99677A" // WindowsSettings
};
// Treat default plugin differently, it needs to be removable along with each flow release
var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID)
@ -530,74 +669,83 @@ namespace Flow.Launcher.Core.Plugin
}
catch (Exception e)
{
Log.Exception($"|PluginManager.InstallPlugin|Failed to delete temp folder {tempFolderPluginPath}", e);
API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e);
}
if (checkModified)
{
_modifiedPlugins.Add(plugin.ID);
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
internal static void UninstallPlugin(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
internal static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
{
if (checkModified && PluginModified(plugin.ID))
{
throw new ArgumentException($"Plugin {plugin.Name} has been modified");
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return false;
}
if (removePluginSettings || removePluginFromSettings)
{
// If we want to remove plugin from AllPlugins,
// we need to dispose them so that they can release file handles
// which can help FL to delete the plugin settings & cache folders successfully
var pluginPairs = AllPlugins.FindAll(p => p.Metadata.ID == plugin.ID);
foreach (var pluginPair in pluginPairs)
{
await DisposePluginAsync(pluginPair);
}
}
if (removePluginSettings)
{
if (AllowedLanguage.IsDotNet(plugin.Language)) // for the plugin in .NET, we can use assembly loader
// For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances
if (AllowedLanguage.IsDotNet(plugin.Language) && API is IRemovable removable)
{
var assemblyLoader = new PluginAssemblyLoader(plugin.ExecuteFilePath);
var assembly = assemblyLoader.LoadAssemblyAndDependencies();
var assemblyName = assembly.GetName().Name;
// if user want to remove the plugin settings, we cannot call save method for the plugin json storage instance of this plugin
// so we need to remove it from the api instance
var method = API.GetType().GetMethod("RemovePluginSettings");
var pluginJsonStorage = method?.Invoke(API, new object[] { assemblyName });
// if there exists a json storage for current plugin, we need to delete the directory path
if (pluginJsonStorage != null)
{
var deleteMethod = pluginJsonStorage.GetType().GetMethod("DeleteDirectory");
try
{
deleteMethod?.Invoke(pluginJsonStorage, null);
}
catch (Exception e)
{
Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"),
string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name));
}
}
removable.RemovePluginSettings(plugin.AssemblyName);
removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath);
}
else // the plugin with json prc interface
try
{
var pluginPair = AllPlugins.FirstOrDefault(p => p.Metadata.ID == plugin.ID);
if (pluginPair != null && pluginPair.Plugin is JsonRPCPlugin jsonRpcPlugin)
{
try
{
jsonRpcPlugin.DeletePluginSettingsDirectory();
}
catch (Exception e)
{
Log.Exception($"|PluginManager.UninstallPlugin|Failed to delete plugin json folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"),
string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name));
}
}
var pluginSettingsDirectory = plugin.PluginSettingsDirectoryPath;
if (Directory.Exists(pluginSettingsDirectory))
Directory.Delete(pluginSettingsDirectory, true);
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"),
string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name));
}
}
if (removePluginFromSettings)
{
Settings.Plugins.Remove(plugin.ID);
try
{
var pluginCacheDirectory = plugin.PluginCacheDirectoryPath;
if (Directory.Exists(pluginCacheDirectory))
Directory.Delete(pluginCacheDirectory, true);
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"),
string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name));
}
Settings.RemovePluginSettings(plugin.ID);
AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID);
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
@ -605,8 +753,10 @@ namespace Flow.Launcher.Core.Plugin
if (checkModified)
{
_modifiedPlugins.Add(plugin.ID);
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
#endregion

View file

@ -11,12 +11,17 @@ using Flow.Launcher.Infrastructure.Logger;
#pragma warning restore IDE0005
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch;
namespace Flow.Launcher.Core.Plugin
{
public static class PluginsLoader
{
private static readonly string ClassName = nameof(PluginsLoader);
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
public static List<PluginPair> Plugins(List<PluginMetadata> metadatas, PluginsSettings settings)
{
var dotnetPlugins = DotNetPlugins(metadatas);
@ -50,7 +55,7 @@ namespace Flow.Launcher.Core.Plugin
return plugins;
}
public static IEnumerable<PluginPair> DotNetPlugins(List<PluginMetadata> source)
private static IEnumerable<PluginPair> DotNetPlugins(List<PluginMetadata> source)
{
var erroredPlugins = new List<string>();
@ -59,8 +64,7 @@ namespace Flow.Launcher.Core.Plugin
foreach (var metadata in metadatas)
{
var milliseconds = Stopwatch.Debug(
$"|PluginsLoader.DotNetPlugins|Constructor init cost for {metadata.Name}", () =>
var milliseconds = API.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () =>
{
Assembly assembly = null;
IAsyncPlugin plugin = null;
@ -74,28 +78,30 @@ namespace Flow.Launcher.Core.Plugin
typeof(IAsyncPlugin));
plugin = Activator.CreateInstance(type) as IAsyncPlugin;
metadata.AssemblyName = assembly.GetName().Name;
}
#if DEBUG
catch (Exception e)
catch (Exception)
{
throw;
}
#else
catch (Exception e) when (assembly == null)
{
Log.Exception($"|PluginsLoader.DotNetPlugins|Couldn't load assembly for the plugin: {metadata.Name}", e);
Log.Exception(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e);
}
catch (InvalidOperationException e)
{
Log.Exception($"|PluginsLoader.DotNetPlugins|Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e);
Log.Exception(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e);
}
catch (ReflectionTypeLoadException e)
{
Log.Exception($"|PluginsLoader.DotNetPlugins|The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e);
Log.Exception(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e);
}
catch (Exception e)
{
Log.Exception($"|PluginsLoader.DotNetPlugins|The following plugin has errored and can not be loaded: <{metadata.Name}>", e);
Log.Exception(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e);
}
#endif
@ -112,7 +118,7 @@ namespace Flow.Launcher.Core.Plugin
if (erroredPlugins.Count > 0)
{
var errorPluginString = String.Join(Environment.NewLine, erroredPlugins);
var errorPluginString = string.Join(Environment.NewLine, erroredPlugins);
var errorMessage = "The following "
+ (erroredPlugins.Count > 1 ? "plugins have " : "plugin has ")
@ -130,23 +136,31 @@ namespace Flow.Launcher.Core.Plugin
return plugins;
}
public static IEnumerable<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> source)
private static IEnumerable<PluginPair> ExecutablePlugins(IEnumerable<PluginMetadata> source)
{
return source
.Where(o => o.Language.Equals(AllowedLanguage.Executable, StringComparison.OrdinalIgnoreCase))
.Select(metadata => new PluginPair
.Select(metadata =>
{
Plugin = new ExecutablePlugin(metadata.ExecuteFilePath), Metadata = metadata
return new PluginPair
{
Plugin = new ExecutablePlugin(metadata.ExecuteFilePath),
Metadata = metadata
};
});
}
public static IEnumerable<PluginPair> ExecutableV2Plugins(IEnumerable<PluginMetadata> source)
private static IEnumerable<PluginPair> ExecutableV2Plugins(IEnumerable<PluginMetadata> source)
{
return source
.Where(o => o.Language.Equals(AllowedLanguage.ExecutableV2, StringComparison.OrdinalIgnoreCase))
.Select(metadata => new PluginPair
.Select(metadata =>
{
Plugin = new ExecutablePluginV2(metadata.ExecuteFilePath), Metadata = metadata
return new PluginPair
{
Plugin = new ExecutablePlugin(metadata.ExecuteFilePath),
Metadata = metadata
};
});
}
}

View file

@ -1,21 +1,19 @@
#nullable enable
using System;
using System.Collections.Generic;
using System;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Plugin;
using Meziantou.Framework.Win32;
using Microsoft.VisualBasic.ApplicationServices;
using Nerdbank.Streams;
#nullable enable
namespace Flow.Launcher.Core.Plugin
{
internal abstract class ProcessStreamPluginV2 : JsonRPCPluginV2
{
private static JobObject _jobObject = new JobObject();
private static readonly JobObject _jobObject = new();
static ProcessStreamPluginV2()
{
@ -66,11 +64,10 @@ namespace Flow.Launcher.Core.Plugin
ClientPipe = new DuplexPipe(reader, writer);
}
public override async Task ReloadDataAsync()
{
var oldProcess = ClientProcess;
ClientProcess = Process.Start(StartInfo);
ClientProcess = Process.Start(StartInfo)!;
ArgumentNullException.ThrowIfNull(ClientProcess);
SetupPipe(ClientProcess);
await base.ReloadDataAsync();
@ -79,7 +76,6 @@ namespace Flow.Launcher.Core.Plugin
oldProcess.Dispose();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using Flow.Launcher.Plugin;
@ -8,10 +8,24 @@ namespace Flow.Launcher.Core.Plugin
{
public static Query Build(string text, Dictionary<string, PluginPair> nonGlobalPlugins)
{
// home query
if (string.IsNullOrEmpty(text))
{
return new Query()
{
Search = string.Empty,
RawQuery = string.Empty,
SearchTerms = Array.Empty<string>(),
ActionKeyword = string.Empty,
IsHomeQuery = true
};
}
// replace multiple white spaces with one white space
var terms = text.Split(Query.TermSeparator, StringSplitOptions.RemoveEmptyEntries);
if (terms.Length == 0)
{ // nothing was typed
{
// nothing was typed
return null;
}
@ -21,25 +35,28 @@ namespace Flow.Launcher.Core.Plugin
string[] searchTerms;
if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled)
{ // use non global plugin for query
{
// use non global plugin for query
actionKeyword = possibleActionKeyword;
search = terms.Length > 1 ? rawQuery[(actionKeyword.Length + 1)..].TrimStart() : string.Empty;
searchTerms = terms[1..];
}
else
{ // non action keyword
{
// non action keyword
actionKeyword = string.Empty;
search = rawQuery.TrimStart();
searchTerms = terms;
}
return new Query ()
return new Query()
{
Search = search,
RawQuery = rawQuery,
SearchTerms = searchTerms,
ActionKeyword = actionKeyword
ActionKeyword = actionKeyword,
IsHomeQuery = false
};
}
}
}
}

View file

@ -1,36 +1,41 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using System.Globalization;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.DependencyInjection;
namespace Flow.Launcher.Core.Resource
{
public class Internationalization
{
private static readonly string ClassName = nameof(Internationalization);
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
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<string> _languageDirectories = new List<string>();
private readonly List<ResourceDictionary> _oldResources = new List<ResourceDictionary>();
private readonly string SystemLanguageCode;
private readonly List<string> _languageDirectories = new();
private readonly List<ResourceDictionary> _oldResources = new();
private static string SystemLanguageCode;
public Internationalization(Settings settings)
{
_settings = settings;
AddFlowLauncherLanguageDirectory();
SystemLanguageCode = GetSystemLanguageCodeAtStartup();
}
private void AddFlowLauncherLanguageDirectory()
@ -39,7 +44,7 @@ namespace Flow.Launcher.Core.Resource
_languageDirectories.Add(directory);
}
private static string GetSystemLanguageCodeAtStartup()
public static void InitSystemLanguageCode()
{
var availableLanguages = AvailableLanguages.GetAvailableLanguages();
@ -60,16 +65,16 @@ namespace Flow.Launcher.Core.Resource
string.Equals(languageCode, threeLetterCode, StringComparison.OrdinalIgnoreCase) ||
string.Equals(languageCode, fullName, StringComparison.OrdinalIgnoreCase))
{
return languageCode;
SystemLanguageCode = languageCode;
}
}
return DefaultLanguageCode;
SystemLanguageCode = DefaultLanguageCode;
}
internal void AddPluginLanguageDirectories(IEnumerable<PluginPair> plugins)
private void AddPluginLanguageDirectories()
{
foreach (var plugin in plugins)
foreach (var plugin in PluginManager.GetTranslationPlugins())
{
var location = Assembly.GetAssembly(plugin.Plugin.GetType()).Location;
var dir = Path.GetDirectoryName(location);
@ -80,7 +85,7 @@ namespace Flow.Launcher.Core.Resource
}
else
{
Log.Error($"|Internationalization.AddPluginLanguageDirectories|Can't find plugin path <{location}> for <{plugin.Metadata.Name}>");
API.LogError(ClassName, $"Can't find plugin path <{location}> for <{plugin.Metadata.Name}>");
}
}
@ -96,6 +101,32 @@ namespace Flow.Launcher.Core.Resource
_oldResources.Clear();
}
/// <summary>
/// Initialize language. Will change app language and plugin language based on settings.
/// </summary>
public async Task InitializeLanguageAsync()
{
// Get actual language
var languageCode = _settings.Language;
if (languageCode == Constant.SystemLanguageCode)
{
languageCode = SystemLanguageCode;
}
// Get language by language code and change language
var language = GetLanguageByLanguageCode(languageCode);
// Add plugin language directories first so that we can load language files from plugins
AddPluginLanguageDirectories();
// Change language
await ChangeLanguageAsync(language);
}
/// <summary>
/// Change language during runtime. Will change app language and plugin language & save settings.
/// </summary>
/// <param name="languageCode"></param>
public void ChangeLanguage(string languageCode)
{
languageCode = languageCode.NonNull();
@ -110,16 +141,21 @@ namespace Flow.Launcher.Core.Resource
// Get language by language code and change language
var language = GetLanguageByLanguageCode(languageCode);
ChangeLanguage(language, isSystem);
// Change language
_ = ChangeLanguageAsync(language);
// Save settings
_settings.Language = isSystem ? Constant.SystemLanguageCode : language.LanguageCode;
}
private Language GetLanguageByLanguageCode(string languageCode)
private static Language GetLanguageByLanguageCode(string languageCode)
{
var lowercase = languageCode.ToLower();
var language = AvailableLanguages.GetAvailableLanguages().FirstOrDefault(o => o.LanguageCode.ToLower() == lowercase);
if (language == null)
{
Log.Error($"|Internationalization.GetLanguageByLanguageCode|Language code can't be found <{languageCode}>");
API.LogError(ClassName, $"Language code can't be found <{languageCode}>");
return AvailableLanguages.English;
}
else
@ -128,26 +164,40 @@ namespace Flow.Launcher.Core.Resource
}
}
private void ChangeLanguage(Language language, bool isSystem)
private async Task ChangeLanguageAsync(Language language)
{
language = language.NonNull();
// Remove old language files and load language
RemoveOldLanguageFiles();
if (language != AvailableLanguages.English)
{
LoadLanguage(language);
}
// Change culture info
ChangeCultureInfo(language.LanguageCode);
// Raise event for plugins after culture is set
await Task.Run(UpdatePluginMetadataTranslations);
}
public static void ChangeCultureInfo(string languageCode)
{
// Culture of main thread
// Use CreateSpecificCulture to preserve possible user-override settings in Windows, if Flow's language culture is the same as Windows's
CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture(language.LanguageCode);
CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture;
// Raise event after culture is set
_settings.Language = isSystem ? Constant.SystemLanguageCode : language.LanguageCode;
_ = Task.Run(() =>
CultureInfo currentCulture;
try
{
UpdatePluginMetadataTranslations();
});
currentCulture = CultureInfo.CreateSpecificCulture(languageCode);
}
catch (CultureNotFoundException)
{
currentCulture = CultureInfo.CreateSpecificCulture(SystemLanguageCode);
}
CultureInfo.CurrentCulture = currentCulture;
CultureInfo.CurrentUICulture = currentCulture;
var thread = Thread.CurrentThread;
thread.CurrentCulture = currentCulture;
thread.CurrentUICulture = currentCulture;
}
public bool PromptShouldUsePinyin(string languageCodeToSet)
@ -212,7 +262,7 @@ namespace Flow.Launcher.Core.Resource
return list;
}
public string GetTranslation(string key)
public static string GetTranslation(string key)
{
var translation = Application.Current.TryFindResource(key);
if (translation is string)
@ -221,17 +271,17 @@ namespace Flow.Launcher.Core.Resource
}
else
{
Log.Error($"|Internationalization.GetTranslation|No Translation for key {key}");
API.LogError(ClassName, $"No Translation for key {key}");
return $"No Translation for key {key}";
}
}
private void UpdatePluginMetadataTranslations()
{
foreach (var p in PluginManager.GetPluginsForInterface<IPluginI18n>())
// Update plugin metadata name & description
foreach (var p in PluginManager.GetTranslationPlugins())
{
var pluginI18N = p.Plugin as IPluginI18n;
if (pluginI18N == null) return;
if (p.Plugin is not IPluginI18n pluginI18N) return;
try
{
p.Metadata.Name = pluginI18N.GetTranslatedPluginTitle();
@ -240,31 +290,31 @@ namespace Flow.Launcher.Core.Resource
}
catch (Exception e)
{
Log.Exception($"|Internationalization.UpdatePluginMetadataTranslations|Failed for <{p.Metadata.Name}>", e);
API.LogException(ClassName, $"Failed for <{p.Metadata.Name}>", e);
}
}
}
public string LanguageFile(string folder, string language)
private static string LanguageFile(string folder, string language)
{
if (Directory.Exists(folder))
{
string path = Path.Combine(folder, language);
var path = Path.Combine(folder, language);
if (File.Exists(path))
{
return path;
}
else
{
Log.Error($"|Internationalization.LanguageFile|Language path can't be found <{path}>");
string english = Path.Combine(folder, DefaultFile);
API.LogError(ClassName, $"Language path can't be found <{path}>");
var english = Path.Combine(folder, DefaultFile);
if (File.Exists(english))
{
return english;
}
else
{
Log.Error($"|Internationalization.LanguageFile|Default English Language path can't be found <{path}>");
API.LogError(ClassName, $"Default English Language path can't be found <{path}>");
return string.Empty;
}
}

View file

@ -1,12 +0,0 @@
using System;
using CommunityToolkit.Mvvm.DependencyInjection;
namespace Flow.Launcher.Core.Resource
{
[Obsolete("InternationalizationManager.Instance is obsolete. Use Ioc.Default.GetRequiredService<Internationalization>() instead.")]
public static class InternationalizationManager
{
public static Internationalization Instance
=> Ioc.Default.GetRequiredService<Internationalization>();
}
}

View file

@ -6,6 +6,7 @@ using System.Windows.Data;
namespace Flow.Launcher.Core.Resource
{
[Obsolete("LocalizationConverter is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")]
public class LocalizationConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)

View file

@ -1,15 +1,19 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Resource
{
public class LocalizedDescriptionAttribute : DescriptionAttribute
{
private readonly Internationalization _translator;
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
private readonly string _resourceKey;
public LocalizedDescriptionAttribute(string resourceKey)
{
_translator = InternationalizationManager.Instance;
_resourceKey = resourceKey;
}
@ -17,7 +21,7 @@ namespace Flow.Launcher.Core.Resource
{
get
{
string description = _translator.GetTranslation(_resourceKey);
string description = API.GetTranslation(_resourceKey);
return string.IsNullOrWhiteSpace(description) ?
string.Format("[[{0}]]", _resourceKey) : description;
}

View file

@ -3,22 +3,31 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Shell;
using System.Windows.Threading;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedModels;
using Microsoft.Win32;
namespace Flow.Launcher.Core.Resource
{
public class Theme
{
#region Properties & Fields
private readonly string ClassName = nameof(Theme);
public bool BlurEnabled { get; private set; }
private const string ThemeMetadataNamePrefix = "Name:";
private const string ThemeMetadataIsDarkPrefix = "IsDark:";
private const string ThemeMetadataHasBlurPrefix = "HasBlur:";
@ -32,12 +41,14 @@ namespace Flow.Launcher.Core.Resource
private string _oldTheme;
private const string Folder = Constant.Themes;
private const string Extension = ".xaml";
private string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder);
private string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder);
private static string DirectoryPath => Path.Combine(Constant.ProgramDirectory, Folder);
private static string UserDirectoryPath => Path.Combine(DataLocation.DataDirectory(), Folder);
public bool BlurEnabled { get; set; }
private Thickness _themeResizeBorderThickness;
private double mainWindowWidth;
#endregion
#region Constructor
public Theme(IPublicAPI publicAPI, Settings settings)
{
@ -49,22 +60,29 @@ namespace Flow.Launcher.Core.Resource
MakeSureThemeDirectoriesExist();
var dicts = Application.Current.Resources.MergedDictionaries;
_oldResource = dicts.First(d =>
_oldResource = dicts.FirstOrDefault(d =>
{
if (d.Source == null)
return false;
if (d.Source == null) return false;
var p = d.Source.AbsolutePath;
var dir = Path.GetDirectoryName(p).NonNull();
var info = new DirectoryInfo(dir);
var f = info.Name;
var e = Path.GetExtension(p);
var found = f == Folder && e == Extension;
return found;
return p.Contains(Folder) && Path.GetExtension(p) == Extension;
});
_oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath);
if (_oldResource != null)
{
_oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath);
}
else
{
_api.LogError(ClassName, "Current theme resource not found. Initializing with default theme.");
_oldTheme = Constant.DefaultTheme;
}
}
#endregion
#region Theme Resources
private void MakeSureThemeDirectoriesExist()
{
foreach (var dir in _themeDirectories.Where(dir => !Directory.Exists(dir)))
@ -75,73 +93,159 @@ namespace Flow.Launcher.Core.Resource
}
catch (Exception e)
{
Log.Exception($"|Theme.MakesureThemeDirectoriesExist|Exception when create directory <{dir}>", e);
_api.LogException(ClassName, $"Exception when create directory <{dir}>", e);
}
}
}
public bool ChangeTheme(string theme)
{
const string defaultTheme = Constant.DefaultTheme;
string path = GetThemePath(theme);
try
{
if (string.IsNullOrEmpty(path))
throw new DirectoryNotFoundException("Theme path can't be found <{path}>");
// reload all resources even if the theme itself hasn't changed in order to pickup changes
// to things like fonts
UpdateResourceDictionary(GetResourceDictionary(theme));
_settings.Theme = theme;
//always allow re-loading default theme, in case of failure of switching to a new theme from default theme
if (_oldTheme != theme || theme == defaultTheme)
{
_oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath);
}
BlurEnabled = Win32Helper.IsBlurTheme();
if (_settings.UseDropShadowEffect && !BlurEnabled)
AddDropShadowEffectToCurrentTheme();
Win32Helper.SetBlurForWindow(Application.Current.MainWindow, BlurEnabled);
}
catch (DirectoryNotFoundException)
{
Log.Error($"|Theme.ChangeTheme|Theme <{theme}> path can't be found");
if (theme != defaultTheme)
{
_api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_path_not_exists"), theme));
ChangeTheme(defaultTheme);
}
return false;
}
catch (XamlParseException)
{
Log.Error($"|Theme.ChangeTheme|Theme <{theme}> fail to parse");
if (theme != defaultTheme)
{
_api.ShowMsgBox(string.Format(InternationalizationManager.Instance.GetTranslation("theme_load_failure_parse_error"), theme));
ChangeTheme(defaultTheme);
}
return false;
}
return true;
}
private void UpdateResourceDictionary(ResourceDictionary dictionaryToUpdate)
{
var dicts = Application.Current.Resources.MergedDictionaries;
// Add new resources
if (!Application.Current.Resources.MergedDictionaries.Contains(dictionaryToUpdate))
{
Application.Current.Resources.MergedDictionaries.Add(dictionaryToUpdate);
}
// Remove old resources
if (_oldResource != null && _oldResource != dictionaryToUpdate &&
Application.Current.Resources.MergedDictionaries.Contains(_oldResource))
{
Application.Current.Resources.MergedDictionaries.Remove(_oldResource);
}
dicts.Remove(_oldResource);
dicts.Add(dictionaryToUpdate);
_oldResource = dictionaryToUpdate;
}
/// <summary>
/// Updates only the font settings and refreshes the UI.
/// </summary>
public void UpdateFonts()
{
try
{
// Load a ResourceDictionary for the specified theme.
var themeName = _settings.Theme;
var dict = GetThemeResourceDictionary(themeName);
// Apply font settings to the theme resource.
ApplyFontSettings(dict);
UpdateResourceDictionary(dict);
// Must apply blur and drop shadow effects
_ = RefreshFrameAsync();
}
catch (Exception e)
{
_api.LogException(ClassName, "Error occurred while updating theme fonts", e);
}
}
/// <summary>
/// Loads and applies font settings to the theme resource.
/// </summary>
private void ApplyFontSettings(ResourceDictionary dict)
{
if (dict["QueryBoxStyle"] is Style queryBoxStyle &&
dict["QuerySuggestionBoxStyle"] is Style querySuggestionBoxStyle)
{
var fontFamily = new FontFamily(_settings.QueryBoxFont);
var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle);
var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight);
var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch);
SetFontProperties(queryBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, true);
SetFontProperties(querySuggestionBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
}
if (dict["ItemTitleStyle"] is Style resultItemStyle &&
dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle &&
dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle &&
dict["ItemHotkeySelectedStyle"] is Style resultHotkeyItemSelectedStyle)
{
var fontFamily = new FontFamily(_settings.ResultFont);
var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultFontStyle);
var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultFontWeight);
var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultFontStretch);
SetFontProperties(resultItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
SetFontProperties(resultItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
SetFontProperties(resultHotkeyItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
SetFontProperties(resultHotkeyItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
}
if (dict["ItemSubTitleStyle"] is Style resultSubItemStyle &&
dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle)
{
var fontFamily = new FontFamily(_settings.ResultSubFont);
var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.ResultSubFontStyle);
var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.ResultSubFontWeight);
var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.ResultSubFontStretch);
SetFontProperties(resultSubItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
SetFontProperties(resultSubItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false);
}
}
/// <summary>
/// Applies font properties to a Style.
/// </summary>
private static void SetFontProperties(Style style, FontFamily fontFamily, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, bool isTextBox)
{
// Remove existing font-related setters
if (isTextBox)
{
// First, find the setters to remove and store them in a list
var settersToRemove = style.Setters
.OfType<Setter>()
.Where(setter =>
setter.Property == Control.FontFamilyProperty ||
setter.Property == Control.FontStyleProperty ||
setter.Property == Control.FontWeightProperty ||
setter.Property == Control.FontStretchProperty)
.ToList();
// Remove each found setter one by one
foreach (var setter in settersToRemove)
{
style.Setters.Remove(setter);
}
// Add New font setter
style.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily));
style.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle));
style.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight));
style.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch));
// Set caret brush (retain existing logic)
var caretBrushPropertyValue = style.Setters.OfType<Setter>().Any(x => x.Property.Name == "CaretBrush");
var foregroundPropertyValue = style.Setters.OfType<Setter>().Where(x => x.Property.Name == "Foreground")
.Select(x => x.Value).FirstOrDefault();
if (!caretBrushPropertyValue && foregroundPropertyValue != null)
style.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue));
}
else
{
var settersToRemove = style.Setters
.OfType<Setter>()
.Where(setter =>
setter.Property == TextBlock.FontFamilyProperty ||
setter.Property == TextBlock.FontStyleProperty ||
setter.Property == TextBlock.FontWeightProperty ||
setter.Property == TextBlock.FontStretchProperty)
.ToList();
foreach (var setter in settersToRemove)
{
style.Setters.Remove(setter);
}
style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, fontFamily));
style.Setters.Add(new Setter(TextBlock.FontStyleProperty, fontStyle));
style.Setters.Add(new Setter(TextBlock.FontWeightProperty, fontWeight));
style.Setters.Add(new Setter(TextBlock.FontStretchProperty, fontStretch));
}
}
private ResourceDictionary GetThemeResourceDictionary(string theme)
{
var uri = GetThemePath(theme);
@ -153,9 +257,7 @@ namespace Flow.Launcher.Core.Resource
return dict;
}
private ResourceDictionary CurrentThemeResourceDictionary() => GetThemeResourceDictionary(_settings.Theme);
public ResourceDictionary GetResourceDictionary(string theme)
private ResourceDictionary GetResourceDictionary(string theme)
{
var dict = GetThemeResourceDictionary(theme);
@ -167,22 +269,22 @@ namespace Flow.Launcher.Core.Resource
var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight);
var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch);
queryBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily));
queryBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle));
queryBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight));
queryBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch));
queryBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily));
queryBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle));
queryBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight));
queryBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch));
var caretBrushPropertyValue = queryBoxStyle.Setters.OfType<Setter>().Any(x => x.Property.Name == "CaretBrush");
var foregroundPropertyValue = queryBoxStyle.Setters.OfType<Setter>().Where(x => x.Property.Name == "Foreground")
.Select(x => x.Value).FirstOrDefault();
if (!caretBrushPropertyValue && foregroundPropertyValue != null) //otherwise BaseQueryBoxStyle will handle styling
queryBoxStyle.Setters.Add(new Setter(TextBox.CaretBrushProperty, foregroundPropertyValue));
queryBoxStyle.Setters.Add(new Setter(TextBoxBase.CaretBrushProperty, foregroundPropertyValue));
// Query suggestion box's font style is aligned with query box
querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontFamilyProperty, fontFamily));
querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStyleProperty, fontStyle));
querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontWeightProperty, fontWeight));
querySuggestionBoxStyle.Setters.Add(new Setter(TextBox.FontStretchProperty, fontStretch));
querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontFamilyProperty, fontFamily));
querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStyleProperty, fontStyle));
querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontWeightProperty, fontWeight));
querySuggestionBoxStyle.Setters.Add(new Setter(Control.FontStretchProperty, fontStretch));
}
if (dict["ItemTitleStyle"] is Style resultItemStyle &&
@ -212,36 +314,20 @@ namespace Flow.Launcher.Core.Resource
Setter[] setters = { fontFamily, fontStyle, fontWeight, fontStretch };
Array.ForEach(
new[] { resultSubItemStyle,resultSubItemSelectedStyle}, o
new[] { resultSubItemStyle, resultSubItemSelectedStyle }, o
=> Array.ForEach(setters, p => o.Setters.Add(p)));
}
/* Ignore Theme Window Width and use setting */
var windowStyle = dict["WindowStyle"] as Style;
var width = _settings.WindowSize;
windowStyle.Setters.Add(new Setter(Window.WidthProperty, width));
mainWindowWidth = (double)width;
windowStyle.Setters.Add(new Setter(FrameworkElement.WidthProperty, width));
return dict;
}
private ResourceDictionary GetCurrentResourceDictionary( )
public ResourceDictionary GetCurrentResourceDictionary()
{
return GetResourceDictionary(_settings.Theme);
}
public List<ThemeData> LoadAvailableThemes()
{
List<ThemeData> themes = new List<ThemeData>();
foreach (var themeDirectory in _themeDirectories)
{
var filePaths = Directory
.GetFiles(themeDirectory)
.Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml"))
.Select(GetThemeDataFromPath);
themes.AddRange(filePaths);
}
return themes.OrderBy(o => o.Name).ToList();
return GetResourceDictionary(_settings.Theme);
}
private ThemeData GetThemeDataFromPath(string path)
@ -263,15 +349,15 @@ namespace Flow.Launcher.Core.Resource
{
if (line.StartsWith(ThemeMetadataNamePrefix, StringComparison.OrdinalIgnoreCase))
{
name = line.Remove(0, ThemeMetadataNamePrefix.Length).Trim();
name = line[ThemeMetadataNamePrefix.Length..].Trim();
}
else if (line.StartsWith(ThemeMetadataIsDarkPrefix, StringComparison.OrdinalIgnoreCase))
{
isDark = bool.Parse(line.Remove(0, ThemeMetadataIsDarkPrefix.Length).Trim());
isDark = bool.Parse(line[ThemeMetadataIsDarkPrefix.Length..].Trim());
}
else if (line.StartsWith(ThemeMetadataHasBlurPrefix, StringComparison.OrdinalIgnoreCase))
{
hasBlur = bool.Parse(line.Remove(0, ThemeMetadataHasBlurPrefix.Length).Trim());
hasBlur = bool.Parse(line[ThemeMetadataHasBlurPrefix.Length..].Trim());
}
}
@ -292,6 +378,93 @@ namespace Flow.Launcher.Core.Resource
return string.Empty;
}
#endregion
#region Get & Change Theme
public ThemeData GetCurrentTheme()
{
var themes = GetAvailableThemes();
var matchingTheme = themes.FirstOrDefault(t => t.FileNameWithoutExtension == _settings.Theme);
if (matchingTheme == null)
{
_api.LogWarn(ClassName, $"No matching theme found for '{_settings.Theme}'. Falling back to the first available theme.");
}
return matchingTheme ?? themes.FirstOrDefault();
}
public List<ThemeData> GetAvailableThemes()
{
List<ThemeData> themes = new List<ThemeData>();
foreach (var themeDirectory in _themeDirectories)
{
var filePaths = Directory
.GetFiles(themeDirectory)
.Where(filePath => filePath.EndsWith(Extension) && !filePath.EndsWith("Base.xaml"))
.Select(GetThemeDataFromPath);
themes.AddRange(filePaths);
}
return themes.OrderBy(o => o.Name).ToList();
}
public bool ChangeTheme(string theme = null)
{
if (string.IsNullOrEmpty(theme))
theme = _settings.Theme;
string path = GetThemePath(theme);
try
{
if (string.IsNullOrEmpty(path))
throw new DirectoryNotFoundException($"Theme path can't be found <{path}>");
// Retrieve theme resource always use the resource with font settings applied.
var resourceDict = GetResourceDictionary(theme);
UpdateResourceDictionary(resourceDict);
_settings.Theme = theme;
//always allow re-loading default theme, in case of failure of switching to a new theme from default theme
if (_oldTheme != theme || theme == Constant.DefaultTheme)
{
_oldTheme = Path.GetFileNameWithoutExtension(_oldResource.Source.AbsolutePath);
}
BlurEnabled = IsBlurTheme();
// Apply blur and drop shadow effect so that we do not need to call it again
_ = RefreshFrameAsync();
return true;
}
catch (DirectoryNotFoundException)
{
_api.LogError(ClassName, $"Theme <{theme}> path can't be found");
if (theme != Constant.DefaultTheme)
{
_api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_path_not_exists"), theme));
ChangeTheme(Constant.DefaultTheme);
}
return false;
}
catch (XamlParseException)
{
_api.LogError(ClassName, $"Theme <{theme}> fail to parse");
if (theme != Constant.DefaultTheme)
{
_api.ShowMsgBox(string.Format(_api.GetTranslation("theme_load_failure_parse_error"), theme));
ChangeTheme(Constant.DefaultTheme);
}
return false;
}
}
#endregion
#region Shadow Effect
public void AddDropShadowEffectToCurrentTheme()
{
var dict = GetCurrentResourceDictionary();
@ -300,7 +473,7 @@ namespace Flow.Launcher.Core.Resource
var effectSetter = new Setter
{
Property = Border.EffectProperty,
Property = UIElement.EffectProperty,
Value = new DropShadowEffect
{
Opacity = 0.3,
@ -310,13 +483,12 @@ namespace Flow.Launcher.Core.Resource
}
};
var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter;
if (marginSetter == null)
if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is not Setter marginSetter)
{
var margin = new Thickness(ShadowExtraMargin, 12, ShadowExtraMargin, ShadowExtraMargin);
marginSetter = new Setter()
{
Property = Border.MarginProperty,
Property = FrameworkElement.MarginProperty,
Value = margin,
};
windowBorderStyle.Setters.Add(marginSetter);
@ -346,14 +518,12 @@ namespace Flow.Launcher.Core.Resource
var dict = GetCurrentResourceDictionary();
var windowBorderStyle = dict["WindowBorderStyle"] as Style;
var effectSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.EffectProperty) as Setter;
var marginSetter = windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == Border.MarginProperty) as Setter;
if (effectSetter != null)
if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == UIElement.EffectProperty) is Setter effectSetter)
{
windowBorderStyle.Setters.Remove(effectSetter);
}
if (marginSetter != null)
if (windowBorderStyle.Setters.FirstOrDefault(setterBase => setterBase is Setter setter && setter.Property == FrameworkElement.MarginProperty) is Setter marginSetter)
{
var currentMargin = (Thickness)marginSetter.Value;
var newMargin = new Thickness(
@ -369,31 +539,393 @@ namespace Flow.Launcher.Core.Resource
UpdateResourceDictionary(dict);
}
public void SetResizeBorderThickness(WindowChrome windowChrome, bool fixedWindowSize)
{
if (fixedWindowSize)
{
windowChrome.ResizeBorderThickness = new Thickness(0);
}
else
{
windowChrome.ResizeBorderThickness = _themeResizeBorderThickness;
}
}
// because adding drop shadow effect will change the margin of the window,
// we need to update the window chrome thickness to correct set the resize border
private static void SetResizeBoarderThickness(Thickness? effectMargin)
private void SetResizeBoarderThickness(Thickness? effectMargin)
{
var window = Application.Current.MainWindow;
if (WindowChrome.GetWindowChrome(window) is WindowChrome windowChrome)
{
Thickness thickness;
// Save the theme resize border thickness so that we can restore it if we change ResizeWindow setting
if (effectMargin == null)
{
thickness = SystemParameters.WindowResizeBorderThickness;
_themeResizeBorderThickness = SystemParameters.WindowResizeBorderThickness;
}
else
{
thickness = new Thickness(
_themeResizeBorderThickness = new Thickness(
effectMargin.Value.Left + SystemParameters.WindowResizeBorderThickness.Left,
effectMargin.Value.Top + SystemParameters.WindowResizeBorderThickness.Top,
effectMargin.Value.Right + SystemParameters.WindowResizeBorderThickness.Right,
effectMargin.Value.Bottom + SystemParameters.WindowResizeBorderThickness.Bottom);
}
windowChrome.ResizeBorderThickness = thickness;
// Apply the resize border thickness to the window chrome
SetResizeBorderThickness(windowChrome, _settings.KeepMaxResults);
}
}
public record ThemeData(string FileNameWithoutExtension, string Name, bool? IsDark = null, bool? HasBlur = null);
#endregion
#region Blur Handling
/// <summary>
/// Refreshes the frame to apply the current theme settings.
/// </summary>
public async Task RefreshFrameAsync()
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
// Get the actual backdrop type and drop shadow effect settings
var (backdropType, useDropShadowEffect) = GetActualValue();
// Remove OS minimizing/maximizing animation
// Methods.SetWindowAttribute(new WindowInteropHelper(mainWindow).Handle, DWMWINDOWATTRIBUTE.DWMWA_TRANSITIONS_FORCEDISABLED, 3);
// The timing of adding the shadow effect should vary depending on whether the theme is transparent.
if (BlurEnabled)
{
AutoDropShadow(useDropShadowEffect);
}
SetBlurForWindow(_settings.Theme, backdropType);
if (!BlurEnabled)
{
AutoDropShadow(useDropShadowEffect);
}
}, DispatcherPriority.Render);
}
/// <summary>
/// Sets the blur for a window via SetWindowCompositionAttribute
/// </summary>
public async Task SetBlurForWindowAsync()
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
// Get the actual backdrop type and drop shadow effect settings
var (backdropType, _) = GetActualValue();
SetBlurForWindow(_settings.Theme, backdropType);
}, DispatcherPriority.Render);
}
/// <summary>
/// Gets the actual backdrop type and drop shadow effect settings based on the current theme status.
/// </summary>
public (BackdropTypes BackdropType, bool UseDropShadowEffect) GetActualValue()
{
var backdropType = _settings.BackdropType;
var useDropShadowEffect = _settings.UseDropShadowEffect;
// When changed non-blur theme, change to backdrop to none
if (!BlurEnabled)
{
backdropType = BackdropTypes.None;
}
// Dropshadow on and control disabled.(user can't change dropshadow with blur theme)
if (BlurEnabled)
{
useDropShadowEffect = true;
}
return (backdropType, useDropShadowEffect);
}
private void SetBlurForWindow(string theme, BackdropTypes backdropType)
{
var dict = GetResourceDictionary(theme);
if (dict == null) return;
var windowBorderStyle = dict.Contains("WindowBorderStyle") ? dict["WindowBorderStyle"] as Style : null;
if (windowBorderStyle == null) return;
var mainWindow = Application.Current.MainWindow;
if (mainWindow == null) return;
// Check if the theme supports blur
bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b;
if (BlurEnabled && hasBlur && Win32Helper.IsBackdropSupported())
{
// If the BackdropType is Mica or MicaAlt, set the windowborderstyle's background to transparent
if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt)
{
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property.Name == "Background"));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Color.FromArgb(1, 0, 0, 0))));
}
else if (backdropType == BackdropTypes.Acrylic)
{
windowBorderStyle.Setters.Remove(windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property.Name == "Background"));
windowBorderStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(Colors.Transparent)));
}
// For themes with blur enabled, the window border is rendered by the system, so it's treated as a simple rectangle regardless of thickness.
//(This is to avoid issues when the window is forcibly changed to a rectangular shape during snap scenarios.)
var cornerRadiusSetter = windowBorderStyle.Setters.OfType<Setter>().FirstOrDefault(x => x.Property == Border.CornerRadiusProperty);
if (cornerRadiusSetter != null)
cornerRadiusSetter.Value = new CornerRadius(0);
else
windowBorderStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(0)));
// Apply the blur effect
Win32Helper.DWMSetBackdropForWindow(mainWindow, backdropType);
ColorizeWindow(theme, backdropType);
}
else
{
// Apply default style when Blur is disabled
Win32Helper.DWMSetBackdropForWindow(mainWindow, BackdropTypes.None);
ColorizeWindow(theme, backdropType);
}
UpdateResourceDictionary(dict);
}
private void AutoDropShadow(bool useDropShadowEffect)
{
SetWindowCornerPreference("Default");
RemoveDropShadowEffectFromCurrentTheme();
if (useDropShadowEffect)
{
if (BlurEnabled && Win32Helper.IsBackdropSupported())
{
SetWindowCornerPreference("Round");
}
else
{
SetWindowCornerPreference("Default");
AddDropShadowEffectToCurrentTheme();
}
}
else
{
if (BlurEnabled && Win32Helper.IsBackdropSupported())
{
SetWindowCornerPreference("Default");
}
else
{
RemoveDropShadowEffectFromCurrentTheme();
}
}
}
private static void SetWindowCornerPreference(string cornerType)
{
Window mainWindow = Application.Current.MainWindow;
if (mainWindow == null)
return;
Win32Helper.DWMSetCornerPreferenceForWindow(mainWindow, cornerType);
}
// Get Background Color from WindowBorderStyle when there not color for BG.
// for theme has not "LightBG" or "DarkBG" case.
private Color GetWindowBorderStyleBackground(string theme)
{
var Resources = GetThemeResourceDictionary(theme);
var windowBorderStyle = (Style)Resources["WindowBorderStyle"];
var backgroundSetter = windowBorderStyle.Setters
.OfType<Setter>()
.FirstOrDefault(s => s.Property == Border.BackgroundProperty);
if (backgroundSetter != null)
{
// Background's Value is DynamicColor Case
var backgroundValue = backgroundSetter.Value;
if (backgroundValue is SolidColorBrush solidColorBrush)
{
return solidColorBrush.Color; // Return SolidColorBrush's Color
}
else if (backgroundValue is DynamicResourceExtension dynamicResource)
{
// When DynamicResource Extension it is, Key is resource's name.
var resourceKey = backgroundSetter.Value.ToString();
// find key in resource and return color.
if (Resources.Contains(resourceKey))
{
var colorResource = Resources[resourceKey];
if (colorResource is SolidColorBrush colorBrush)
{
return colorBrush.Color;
}
else if (colorResource is Color color)
{
return color;
}
}
}
}
return Colors.Transparent; // Default is transparent
}
private void ApplyPreviewBackground(Color? bgColor = null)
{
if (bgColor == null) return;
// Create a new Style for the preview
var previewStyle = new Style(typeof(Border));
// Get the original WindowBorderStyle
if (Application.Current.Resources.Contains("WindowBorderStyle") &&
Application.Current.Resources["WindowBorderStyle"] is Style originalStyle)
{
// Copy the original style, including the base style if it exists
CopyStyle(originalStyle, previewStyle);
}
// Apply background color (remove transparency in color)
Color backgroundColor = Color.FromRgb(bgColor.Value.R, bgColor.Value.G, bgColor.Value.B);
previewStyle.Setters.Add(new Setter(Border.BackgroundProperty, new SolidColorBrush(backgroundColor)));
// The blur theme keeps the corner round fixed (applying DWM code to modify it causes rendering issues).
// The non-blur theme retains the previously set WindowBorderStyle.
if (BlurEnabled)
{
previewStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(5)));
previewStyle.Setters.Add(new Setter(Border.BorderThicknessProperty, new Thickness(1)));
}
// Set the new style to the resource
Application.Current.Resources["PreviewWindowBorderStyle"] = previewStyle;
}
private void CopyStyle(Style originalStyle, Style targetStyle)
{
// If the style is based on another style, copy the base style first
if (originalStyle.BasedOn != null)
{
CopyStyle(originalStyle.BasedOn, targetStyle);
}
// Copy the setters from the original style
foreach (var setter in originalStyle.Setters.OfType<Setter>())
{
targetStyle.Setters.Add(new Setter(setter.Property, setter.Value));
}
}
private void ColorizeWindow(string theme, BackdropTypes backdropType)
{
var dict = GetThemeResourceDictionary(theme);
if (dict == null) return;
var mainWindow = Application.Current.MainWindow;
if (mainWindow == null) return;
// Check if the theme supports blur
bool hasBlur = dict.Contains("ThemeBlurEnabled") && dict["ThemeBlurEnabled"] is bool b && b;
// SystemBG value check (Auto, Light, Dark)
string systemBG = dict.Contains("SystemBG") ? dict["SystemBG"] as string : "Auto"; // 기본값 Auto
// Check the user's ColorScheme setting
string colorScheme = _settings.ColorScheme;
// Check system dark mode setting (read AppsUseLightTheme value)
int themeValue = (int)Registry.GetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme", 1);
bool isSystemDark = themeValue == 0;
// Final decision on whether to use dark mode
bool useDarkMode = false;
// If systemBG is not "Auto", prioritize it over ColorScheme and set the mode based on systemBG value
if (systemBG == "Dark")
{
useDarkMode = true; // Dark
}
else if (systemBG == "Light")
{
useDarkMode = false; // Light
}
else if (systemBG == "Auto")
{
// If systemBG is "Auto", decide based on ColorScheme
if (colorScheme == "Dark")
useDarkMode = true;
else if (colorScheme == "Light")
useDarkMode = false;
else
useDarkMode = isSystemDark; // Auto (based on system setting)
}
// Apply DWM Dark Mode
Win32Helper.DWMSetDarkModeForWindow(mainWindow, useDarkMode);
Color LightBG;
Color DarkBG;
// Retrieve LightBG value (fallback to WindowBorderStyle background color if not found)
try
{
LightBG = dict.Contains("LightBG") ? (Color)dict["LightBG"] : GetWindowBorderStyleBackground(theme);
}
catch (Exception)
{
LightBG = GetWindowBorderStyleBackground(theme);
}
// Retrieve DarkBG value (fallback to LightBG if not found)
try
{
DarkBG = dict.Contains("DarkBG") ? (Color)dict["DarkBG"] : LightBG;
}
catch (Exception)
{
DarkBG = LightBG;
}
// Select background color based on ColorScheme and SystemBG
Color selectedBG = useDarkMode ? DarkBG : LightBG;
ApplyPreviewBackground(selectedBG);
bool isBlurAvailable = hasBlur && Win32Helper.IsBackdropSupported(); // Windows 11 미만이면 hasBlur를 강제 false
if (!isBlurAvailable)
{
mainWindow.Background = Brushes.Transparent;
}
else
{
// Only set the background to transparent if the theme supports blur
if (backdropType == BackdropTypes.Mica || backdropType == BackdropTypes.MicaAlt)
{
mainWindow.Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
}
else
{
mainWindow.Background = new SolidColorBrush(selectedBG);
}
}
}
private static bool IsBlurTheme()
{
if (!Win32Helper.IsBackdropSupported()) // Windows 11 미만이면 무조건 false
return false;
var resource = Application.Current.TryFindResource("ThemeBlurEnabled");
return resource is bool b && b;
}
#endregion
}
}

View file

@ -1,12 +0,0 @@
using System;
using CommunityToolkit.Mvvm.DependencyInjection;
namespace Flow.Launcher.Core.Resource
{
[Obsolete("ThemeManager.Instance is obsolete. Use Ioc.Default.GetRequiredService<Theme>() instead.")]
public class ThemeManager
{
public static Theme Instance
=> Ioc.Default.GetRequiredService<Theme>();
}
}

View file

@ -1,19 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace Flow.Launcher.Core.Resource
{
public class TranslationConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var key = value.ToString();
if (String.IsNullOrEmpty(key))
return key;
return InternationalizationManager.Instance.GetTranslation(key);
}
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) => throw new System.InvalidOperationException();
}
}

View file

@ -0,0 +1,19 @@
namespace Flow.Launcher.Core.Storage;
/// <summary>
/// Remove storage instances from <see cref="Launcher.Plugin.IPublicAPI"/> instance
/// </summary>
public interface IRemovable
{
/// <summary>
/// Remove all <see cref="Infrastructure.Storage.PluginJsonStorage{T}"/> instances of one plugin
/// </summary>
/// <param name="assemblyName"></param>
public void RemovePluginSettings(string assemblyName);
/// <summary>
/// Remove all <see cref="Infrastructure.Storage.PluginBinaryStorage{T}"/> instances of one plugin
/// </summary>
/// <param name="cacheDirectory"></param>
public void RemovePluginCaches(string cacheDirectory);
}

View file

@ -4,19 +4,18 @@ using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using JetBrains.Annotations;
using Squirrel;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Plugin.SharedCommands;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Http;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using System.Text.Json.Serialization;
using System.Threading;
using JetBrains.Annotations;
using Squirrel;
namespace Flow.Launcher.Core
{
@ -24,6 +23,8 @@ namespace Flow.Launcher.Core
{
public string GitHubRepository { get; init; }
private static readonly string ClassName = nameof(Updater);
private readonly IPublicAPI _api;
public Updater(IPublicAPI publicAPI, string gitHubRepository)
@ -48,10 +49,11 @@ namespace Flow.Launcher.Core
// UpdateApp CheckForUpdate will return value only if the app is squirrel installed
var newUpdateInfo = await updateManager.CheckForUpdate().NonNull().ConfigureAwait(false);
var newReleaseVersion = Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString());
var currentVersion = Version.Parse(Constant.Version);
var newReleaseVersion =
SemanticVersioning.Version.Parse(newUpdateInfo.FutureReleaseEntry.Version.ToString());
var currentVersion = SemanticVersioning.Version.Parse(Constant.Version);
Log.Info($"|Updater.UpdateApp|Future Release <{newUpdateInfo.FutureReleaseEntry.Formatted()}>");
_api.LogInfo(ClassName, $"Future Release <{Formatted(newUpdateInfo.FutureReleaseEntry)}>");
if (newReleaseVersion <= currentVersion)
{
@ -70,10 +72,13 @@ namespace Flow.Launcher.Core
if (DataLocation.PortableDataLocationInUse())
{
var targetDestination = updateManager.RootAppDirectory + $"\\app-{newReleaseVersion.ToString()}\\{DataLocation.PortableFolderName}";
var targetDestination = updateManager.RootAppDirectory +
$"\\app-{newReleaseVersion}\\{DataLocation.PortableFolderName}";
FilesFolders.CopyAll(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s));
if (!FilesFolders.VerifyBothFolderFilesEqual(DataLocation.PortableDataPath, targetDestination, (s) => _api.ShowMsgBox(s)))
_api.ShowMsgBox(string.Format(_api.GetTranslation("update_flowlauncher_fail_moving_portable_user_profile_data"),
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));
}
@ -84,20 +89,27 @@ namespace Flow.Launcher.Core
var newVersionTips = NewVersionTips(newReleaseVersion.ToString());
Log.Info($"|Updater.UpdateApp|Update success:{newVersionTips}");
_api.LogInfo(ClassName, $"Update success:{newVersionTips}");
if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"), MessageBoxButton.YesNo) == MessageBoxResult.Yes)
if (_api.ShowMsgBox(newVersionTips, _api.GetTranslation("update_flowlauncher_new_update"),
MessageBoxButton.YesNo) == MessageBoxResult.Yes)
{
UpdateManager.RestartApp(Constant.ApplicationFileName);
}
}
catch (Exception e)
{
if ((e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException))
Log.Exception($"|Updater.UpdateApp|Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e);
if (e is HttpRequestException or WebException or SocketException ||
e.InnerException is TimeoutException)
{
_api.LogException(ClassName,
$"Check your connection and proxy settings to github-cloud.s3.amazonaws.com.", e);
}
else
Log.Exception($"|Updater.UpdateApp|Error Occurred", e);
{
_api.LogException(ClassName, $"Error Occurred", e);
}
if (!silentUpdate)
_api.ShowMsg(_api.GetTranslation("update_flowlauncher_fail"),
_api.GetTranslation("update_flowlauncher_check_connection"));
@ -111,32 +123,26 @@ namespace Flow.Launcher.Core
[UsedImplicitly]
private class GithubRelease
{
[JsonPropertyName("prerelease")]
public bool Prerelease { get; [UsedImplicitly] set; }
[JsonPropertyName("prerelease")] public bool Prerelease { get; [UsedImplicitly] set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; [UsedImplicitly] set; }
[JsonPropertyName("published_at")] public DateTime PublishedAt { get; [UsedImplicitly] set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; [UsedImplicitly] set; }
[JsonPropertyName("html_url")] public string HtmlUrl { get; [UsedImplicitly] set; }
}
// https://github.com/Squirrel/Squirrel.Windows/blob/master/src/Squirrel/UpdateManager.Factory.cs
private async Task<UpdateManager> GitHubUpdateManagerAsync(string repository)
private static async Task<UpdateManager> GitHubUpdateManagerAsync(string repository)
{
var uri = new Uri(repository);
var api = $"https://api.github.com/repos{uri.AbsolutePath}/releases";
await using var jsonStream = await Http.GetStreamAsync(api).ConfigureAwait(false);
var releases = await System.Text.Json.JsonSerializer.DeserializeAsync<List<GithubRelease>>(jsonStream).ConfigureAwait(false);
var releases = await JsonSerializer.DeserializeAsync<List<GithubRelease>>(jsonStream).ConfigureAwait(false);
var latest = releases.Where(r => !r.Prerelease).OrderByDescending(r => r.PublishedAt).First();
var latestUrl = latest.HtmlUrl.Replace("/tag/", "/download/");
var client = new WebClient
{
Proxy = Http.WebProxy
};
var client = new WebClient { Proxy = Http.WebProxy };
var downloader = new FileDownloader(client);
var manager = new UpdateManager(latestUrl, urlDownloader: downloader);
@ -144,12 +150,18 @@ namespace Flow.Launcher.Core
return manager;
}
public string NewVersionTips(string version)
private string NewVersionTips(string version)
{
var translator = InternationalizationManager.Instance;
var tips = string.Format(translator.GetTranslation("newVersionTips"), version);
var tips = string.Format(_api.GetTranslation("newVersionTips"), version);
return tips;
}
private static string Formatted<T>(T t)
{
var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions { WriteIndented = true });
return formatted;
}
}
}

View file

@ -0,0 +1,238 @@
{
"version": 1,
"dependencies": {
"net9.0-windows7.0": {
"Droplex": {
"type": "Direct",
"requested": "[1.7.0, )",
"resolved": "1.7.0",
"contentHash": "wutfIus/Ufw/9TDsp86R1ycnIH+wWrj4UhcmrzAHWjsdyC2iM07WEQ9+APTB7pQynsDnYH1r2i58XgAJ3lxUXA==",
"dependencies": {
"YamlDotNet": "9.1.0"
}
},
"FSharp.Core": {
"type": "Direct",
"requested": "[9.0.101, )",
"resolved": "9.0.101",
"contentHash": "3/YR1SDWFA+Ojx9HiBwND+0UR8ZWoeZfkhD0DWAPCDdr/YI+CyFkArmMGzGSyPXeYtjG0sy0emzfyNwjt7zhig=="
},
"Meziantou.Framework.Win32.Jobs": {
"type": "Direct",
"requested": "[3.4.0, )",
"resolved": "3.4.0",
"contentHash": "5GGLckfpwoC1jznInEYfK2INrHyD7K1RtwZJ98kNPKBU6jeu24i4zfgDGHHfb+eK3J+eFPAxo0aYcbUxNXIbNw=="
},
"Microsoft.IO.RecyclableMemoryStream": {
"type": "Direct",
"requested": "[3.0.1, )",
"resolved": "3.0.1",
"contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g=="
},
"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.20.20, )",
"resolved": "2.20.20",
"contentHash": "gwG7KViLbSWS7EI0kYevinVmIga9wZNrpSY/FnWyC6DbdjKJ1xlv/FV1L9b0rLkVP8cGxfIMexdvo/+2W5eq6Q==",
"dependencies": {
"MessagePack": "2.5.187",
"Microsoft.VisualStudio.Threading": "17.10.48",
"Microsoft.VisualStudio.Threading.Analyzers": "17.10.48",
"Microsoft.VisualStudio.Validation": "17.8.8",
"Nerdbank.Streams": "2.11.74",
"Newtonsoft.Json": "13.0.1",
"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.3",
"contentHash": "Vo/39qcam5Xe+DbyfH0JZyqPswdOoa7jv4PGtRJ6Wj8AU+aZ+TuJRlJcIe+MQjRTJwliI8k8VSQpN8sEoBIv2g=="
},
"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=="
},
"JetBrains.Annotations": {
"type": "Transitive",
"resolved": "2024.3.0",
"contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug=="
},
"MemoryPack": {
"type": "Transitive",
"resolved": "1.21.3",
"contentHash": "cwCtED8y400vMWx/Vp0QCSeEpVFjDU4JwF52VX9WTaqVERUvNqjG9n6osFlmFuytegyXnHvYEu1qRJ8rv/rkbg==",
"dependencies": {
"MemoryPack.Core": "1.21.3",
"MemoryPack.Generator": "1.21.3"
}
},
"MemoryPack.Core": {
"type": "Transitive",
"resolved": "1.21.3",
"contentHash": "ajrYoBWT2aKeH4tlY8q/1C9qK1R/NK+7FkuVOX58ebOSxkABoFTqCR7W+Zk2rakUHZiEgNdRqO67hiRZPq6fLA=="
},
"MemoryPack.Generator": {
"type": "Transitive",
"resolved": "1.21.3",
"contentHash": "hYU0TAIarDKnbkNIWvb7P4zBUL+CTahkuNkczsKvycSMR5kiwQ4IfLexywNKX3s05Izp4gzDSPbueepNWZRpWA=="
},
"MessagePack": {
"type": "Transitive",
"resolved": "2.5.187",
"contentHash": "uW4j8m4Nc+2Mk5n6arOChavJ9bLjkis0qWASOj2h2OwmfINuzYv+mjCHUymrYhmyyKTu3N+ObtTXAY4uQ7jIhg==",
"dependencies": {
"MessagePack.Annotations": "2.5.187",
"Microsoft.NET.StringTools": "17.6.3"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.187",
"contentHash": "/IvvMMS8opvlHjEJ/fR2Cal4Co726Kj77Z8KiohFhuHfLHHmb9uUxW5+tSCL4ToKFfkQlrS3HD638mRq83ySqA=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
"Microsoft.VisualStudio.Threading": {
"type": "Transitive",
"resolved": "17.12.19",
"contentHash": "eLiGMkMYyaSguqHs3lsrFxy3tAWSLuPEL2pIWRcADMDVAs2xqm3dr1d9QYjiEusTgiClF9KD6OB2NdZP72Oy0Q==",
"dependencies": {
"Microsoft.VisualStudio.Threading.Analyzers": "17.12.19",
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Microsoft.VisualStudio.Threading.Analyzers": {
"type": "Transitive",
"resolved": "17.12.19",
"contentHash": "v3IYeedjoktvZ+GqYmLudxZJngmf/YWIxNT2Uy6QMMN19cvw+nkWoip1Gr1RtnFkUo1MPUVMis4C8Kj8d8DpSQ=="
},
"Microsoft.VisualStudio.Validation": {
"type": "Transitive",
"resolved": "17.8.8",
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "9.0.2",
"contentHash": "5BkGZ6mHp2dHydR29sb0fDfAuqkv30AHtTih8wMzvPZysOmBFvHfnkR2w3tsc0pSiIg8ZoKyefJXWy9r3pBh0w=="
},
"Mono.Cecil": {
"type": "Transitive",
"resolved": "0.9.6.1",
"contentHash": "yMsurNaOxxKIjyW9pEB+tRrR1S3DFnN1+iBgKvYvXG8kW0Y6yknJeMAe/tl3+P78/2C6304TgF7aVqpqXgEQ9Q=="
},
"Nerdbank.Streams": {
"type": "Transitive",
"resolved": "2.11.74",
"contentHash": "r4G7uHHfoo8LCilPOdtf2C+Q5ymHOAXtciT4ZtB2xRlAvv4gPkWBYNAijFblStv3+uidp81j5DP11jMZl4BfJw==",
"dependencies": {
"Microsoft.VisualStudio.Threading": "17.10.48",
"Microsoft.VisualStudio.Validation": "17.8.8",
"System.IO.Pipelines": "8.0.0"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.1",
"contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A=="
},
"NLog": {
"type": "Transitive",
"resolved": "4.7.10",
"contentHash": "rcegW7kYOCjl7wX0SzsqpPBqnJ51JKi1WkYb6QBVX0Wc5IgH19Pv4t/co+T0s06OS0Ne44xgkY/mHg0PdrmJow=="
},
"PropertyChanged.Fody": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "IAZyq0uolKo2WYm4mjx+q7A8fSGFT0x2e1s3y+ODn4JI0kqTDoo9GF2tdaypUzRFJZfdMxfC5HZW9QzdJLtOnA==",
"dependencies": {
"Fody": "6.5.1"
}
},
"Splat": {
"type": "Transitive",
"resolved": "1.6.2",
"contentHash": "DeH0MxPU+D4JchkIDPYG4vUT+hsWs9S41cFle0/4K5EJMXWurx5DzAkj2366DfK14/XKNhsu6tCl4dZXJ3CD4w=="
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "9.0.2",
"contentHash": "JU947wzf8JbBS16Y5EIZzAlyQU+k68D7LRx6y03s2wlhlvLqkt/8uPBrjv2hJnnaJKbdb0GhQ3JZsfYXhrRjyg==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "9.0.2"
}
},
"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.0.1.4",
"contentHash": "uQo97618y9yzLDxrnehPN+/tuiOlk5BqieEdwctHZOAS9miMXnHKgMFYVw8CSGXRglyTYXlrW7qtUlU7Fje5Ew=="
},
"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.3, )",
"CommunityToolkit.Mvvm": "[8.4.0, )",
"Flow.Launcher.Plugin": "[4.4.0, )",
"MemoryPack": "[1.21.3, )",
"Microsoft.VisualStudio.Threading": "[17.12.19, )",
"NLog": "[4.7.10, )",
"PropertyChanged.Fody": "[3.4.0, )",
"System.Drawing.Common": "[9.0.2, )",
"ToolGood.Words.Pinyin": "[3.0.1.4, )"
}
},
"flow.launcher.plugin": {
"type": "Project",
"dependencies": {
"JetBrains.Annotations": "[2024.3.0, )",
"PropertyChanged.Fody": "[3.4.0, )"
}
}
}
}
}

View file

@ -32,6 +32,7 @@ namespace Flow.Launcher.Infrastructure
public static readonly string MissingImgIcon = Path.Combine(ImagesDirectory, "app_missing_img.png");
public static readonly string LoadingImgIcon = Path.Combine(ImagesDirectory, "loading.png");
public static readonly string ImageIcon = Path.Combine(ImagesDirectory, "image.png");
public static readonly string HistoryIcon = Path.Combine(ImagesDirectory, "history.png");
public static string PythonPath;
public static string NodePath;
@ -47,6 +48,7 @@ namespace Flow.Launcher.Infrastructure
public const string Themes = "Themes";
public const string Settings = "Settings";
public const string Logs = "Logs";
public const string Cache = "Cache";
public const string Website = "https://flowlauncher.com";
public const string SponsorPage = "https://github.com/sponsors/Flow-Launcher";

View file

@ -67,8 +67,6 @@ namespace Flow.Launcher.Infrastructure
return explorerWindows.Zip(zOrders).MinBy(x => x.Second).First;
}
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
/// <summary>
/// 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.
/// </summary>

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net9.0-windows</TargetFramework>
<ProjectGuid>{4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}</ProjectGuid>
<OutputType>Library</OutputType>
<UseWpf>true</UseWpf>
@ -12,6 +12,7 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
@ -66,12 +67,14 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NLog" Version="4.7.10" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="SharpVectors.Wpf" Version="1.8.4.2" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
<!--ToolGood.Words.Pinyin v3.0.2.6 results in high memory usage when search with pinyin is enabled-->
<!--Bumping to it or higher needs to test and ensure this is no longer a problem-->
<PackageReference Include="ToolGood.Words.Pinyin" Version="3.0.1.4" />
</ItemGroup>
</Project>

View file

@ -1,19 +1,11 @@
#nullable enable
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Flow.Launcher.Infrastructure
{
public static class Helper
{
static Helper()
{
jsonFormattedSerializerOptions.Converters.Add(new JsonStringEnumConverter());
}
/// <summary>
/// http://www.yinwang.org/blog-cn/2015/11/21/programming-philosophy
/// </summary>
@ -36,55 +28,5 @@ namespace Flow.Launcher.Infrastructure
throw new NullReferenceException();
}
}
public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory)
{
if (!Directory.Exists(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory))
{
var data = Path.GetFileName(bundledDataPath);
var dataPath = Path.Combine(dataDirectory, data.NonNull());
if (!File.Exists(dataPath))
{
File.Copy(bundledDataPath, dataPath);
}
else
{
var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc;
var time2 = new FileInfo(dataPath).LastWriteTimeUtc;
if (time1 != time2)
{
File.Copy(bundledDataPath, dataPath, true);
}
}
}
}
public static void ValidateDirectory(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
private static readonly JsonSerializerOptions jsonFormattedSerializerOptions = new JsonSerializerOptions
{
WriteIndented = true
};
public static string Formatted<T>(this T t)
{
var formatted = JsonSerializer.Serialize(t, new JsonSerializerOptions
{
WriteIndented = true
});
return formatted;
}
}
}

View file

@ -1,30 +1,31 @@
using System.IO;
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using System;
using System.Threading;
using Flow.Launcher.Plugin;
using CommunityToolkit.Mvvm.DependencyInjection;
using JetBrains.Annotations;
namespace Flow.Launcher.Infrastructure.Http
{
public static class Http
{
private static readonly string ClassName = nameof(Http);
private const string UserAgent = @"Mozilla/5.0 (Trident/7.0; rv:11.0) like Gecko";
private static HttpClient client = new HttpClient();
private static readonly HttpClient client = new();
static Http()
{
// need to be added so it would work on a win10 machine
ServicePointManager.Expect100Continue = true;
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls
| SecurityProtocolType.Tls11
| SecurityProtocolType.Tls12;
| SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
HttpClient.DefaultProxy = WebProxy;
@ -34,7 +35,7 @@ namespace Flow.Launcher.Infrastructure.Http
public static HttpProxy Proxy
{
private get { return proxy; }
private get => proxy;
set
{
proxy = value;
@ -72,13 +73,13 @@ namespace Flow.Launcher.Infrastructure.Http
ProxyProperty.Port => (new Uri($"http://{Proxy.Server}:{Proxy.Port}"), WebProxy.Credentials),
ProxyProperty.UserName => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)),
ProxyProperty.Password => (WebProxy.Address, new NetworkCredential(Proxy.UserName, Proxy.Password)),
_ => throw new ArgumentOutOfRangeException()
_ => throw new ArgumentOutOfRangeException(null)
};
}
catch (UriFormatException e)
{
Ioc.Default.GetRequiredService<IPublicAPI>().ShowMsg("Please try again", "Unable to parse Http Proxy");
Log.Exception("Flow.Launcher.Infrastructure.Http", "Unable to parse Uri", e);
Log.Exception(ClassName, "Unable to parse Uri", e);
}
}
@ -134,7 +135,7 @@ namespace Flow.Launcher.Infrastructure.Http
}
catch (HttpRequestException e)
{
Log.Exception("Infrastructure.Http", "Http Request Error", e, "DownloadAsync");
Log.Exception(ClassName, "Http Request Error", e, "DownloadAsync");
throw;
}
}
@ -147,7 +148,7 @@ namespace Flow.Launcher.Infrastructure.Http
/// <returns>The Http result as string. Null if cancellation requested</returns>
public static Task<string> GetAsync([NotNull] string url, CancellationToken token = default)
{
Log.Debug($"|Http.Get|Url <{url}>");
Log.Debug(ClassName, $"Url <{url}>");
return GetAsync(new Uri(url), token);
}
@ -159,7 +160,7 @@ namespace Flow.Launcher.Infrastructure.Http
/// <returns>The Http result as string. Null if cancellation requested</returns>
public static async Task<string> GetAsync([NotNull] Uri url, CancellationToken token = default)
{
Log.Debug($"|Http.Get|Url <{url}>");
Log.Debug(ClassName, $"Url <{url}>");
using var response = await client.GetAsync(url, token);
var content = await response.Content.ReadAsStringAsync(token);
if (response.StatusCode != HttpStatusCode.OK)
@ -181,7 +182,6 @@ namespace Flow.Launcher.Infrastructure.Http
public static Task<Stream> GetStreamAsync([NotNull] string url,
CancellationToken token = default) => GetStreamAsync(new Uri(url), token);
/// <summary>
/// Send a GET request to the specified Uri with an HTTP completion option and a cancellation token as an asynchronous operation.
/// </summary>
@ -191,7 +191,7 @@ namespace Flow.Launcher.Infrastructure.Http
public static async Task<Stream> GetStreamAsync([NotNull] Uri url,
CancellationToken token = default)
{
Log.Debug($"|Http.Get|Url <{url}>");
Log.Debug(ClassName, $"Url <{url}>");
return await client.GetStreamAsync(url, token);
}
@ -202,7 +202,7 @@ namespace Flow.Launcher.Infrastructure.Http
public static async Task<HttpResponseMessage> GetResponseAsync([NotNull] Uri url, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
CancellationToken token = default)
{
Log.Debug($"|Http.Get|Url <{url}>");
Log.Debug(ClassName, $"Url <{url}>");
return await client.GetAsync(url, completionOption, token);
}
@ -211,7 +211,27 @@ namespace Flow.Launcher.Infrastructure.Http
/// </summary>
public static async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken token = default)
{
return await client.SendAsync(request, completionOption, token);
try
{
return await client.SendAsync(request, completionOption, token);
}
catch (System.Exception)
{
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
}
}
public static async Task<string> GetStringAsync(string url, CancellationToken token = default)
{
try
{
Log.Debug(ClassName, $"Url <{url}>");
return await client.GetStringAsync(url, token);
}
catch (System.Exception e)
{
return string.Empty;
}
}
}
}

View file

@ -0,0 +1,22 @@
namespace Flow.Launcher.Infrastructure
{
/// <summary>
/// Translate a language to English letters using a given rule.
/// </summary>
public interface IAlphabet
{
/// <summary>
/// Translate a string to English letters, using a given rule.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public (string translation, TranslationMapping map) Translate(string stringToTranslate);
/// <summary>
/// Determine if a string should be translated to English letter with this Alphabet.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public bool ShouldTranslate(string stringToTranslate);
}
}

View file

@ -1,8 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media;
using BitFaster.Caching.Lfu;
@ -55,7 +53,6 @@ namespace Flow.Launcher.Infrastructure.Image
return image != null;
}
image = null;
return false;
}

View file

@ -5,17 +5,19 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.Storage;
using static Flow.Launcher.Infrastructure.Http.Http;
using SharpVectors.Converters;
using SharpVectors.Renderers.Wpf;
namespace Flow.Launcher.Infrastructure.Image
{
public static class ImageLoader
{
private static readonly string ClassName = nameof(ImageLoader);
private static readonly ImageCache ImageCache = new();
private static SemaphoreSlim storageLock { get; } = new SemaphoreSlim(1, 1);
private static BinaryStorage<List<(string, bool)>> _storage;
@ -27,9 +29,10 @@ namespace Flow.Launcher.Infrastructure.Image
public static ImageSource LoadingImage { get; } = new BitmapImage(new Uri(Constant.LoadingImgIcon));
public const int SmallIconSize = 64;
public const int FullIconSize = 256;
public const int FullImageSize = 320;
private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" };
private static readonly string SvgExtension = ".svg";
public static async Task InitializeAsync()
{
@ -37,6 +40,7 @@ namespace Flow.Launcher.Infrastructure.Image
_hashGenerator = new ImageHashGenerator();
var usage = await LoadStorageToConcurrentDictionaryAsync();
_storage.ClearData();
ImageCache.Initialize(usage);
@ -49,19 +53,18 @@ namespace Flow.Launcher.Infrastructure.Image
_ = Task.Run(async () =>
{
await Stopwatch.NormalAsync("|ImageLoader.Initialize|Preload images cost", async () =>
await Stopwatch.InfoAsync(ClassName, "Preload images cost", async () =>
{
foreach (var (path, isFullImage) in usage)
{
await LoadAsync(path, isFullImage);
}
});
Log.Info(
$"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}");
Log.Info(ClassName, $"Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}");
});
}
public static async Task Save()
public static async Task SaveAsync()
{
await storageLock.WaitAsync();
@ -71,12 +74,22 @@ namespace Flow.Launcher.Infrastructure.Image
.Select(x => x.Key)
.ToList());
}
catch (System.Exception e)
{
Log.Exception(ClassName, "Failed to save image cache to file", e);
}
finally
{
storageLock.Release();
}
}
public static async Task WaitSaveAsync()
{
await storageLock.WaitAsync();
storageLock.Release();
}
private static async Task<List<(string, bool)>> LoadStorageToConcurrentDictionaryAsync()
{
await storageLock.WaitAsync();
@ -158,8 +171,8 @@ namespace Flow.Launcher.Infrastructure.Image
}
catch (System.Exception e2)
{
Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on first try", e);
Log.Exception($"|ImageLoader.Load|Failed to get thumbnail for {path} on second try", e2);
Log.Exception(ClassName, $"Failed to get thumbnail for {path} on first try", e);
Log.Exception(ClassName, $"Failed to get thumbnail for {path} on second try", e2);
ImageSource image = ImageCache[Constant.MissingImgIcon, false];
ImageCache[path, false] = image;
@ -173,7 +186,7 @@ namespace Flow.Launcher.Infrastructure.Image
private static async Task<BitmapImage> LoadRemoteImageAsync(bool loadFullImage, Uri uriResult)
{
// Download image from url
await using var resp = await GetStreamAsync(uriResult);
await using var resp = await Http.Http.GetStreamAsync(uriResult);
await using var buffer = new MemoryStream();
await resp.CopyToAsync(buffer);
buffer.Seek(0, SeekOrigin.Begin);
@ -221,10 +234,11 @@ namespace Flow.Launcher.Infrastructure.Image
image = LoadFullImage(path);
type = ImageType.FullImageFile;
}
catch (NotSupportedException)
catch (NotSupportedException ex)
{
image = Image;
type = ImageType.Error;
Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex);
}
}
else
@ -237,6 +251,20 @@ namespace Flow.Launcher.Infrastructure.Image
image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly);
}
}
else if (extension == SvgExtension)
{
try
{
image = LoadSvgImage(path, loadFullImage);
type = ImageType.FullImageFile;
}
catch (System.Exception ex)
{
image = Image;
type = ImageType.Error;
Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex);
}
}
else
{
type = ImageType.File;
@ -277,7 +305,7 @@ namespace Flow.Launcher.Infrastructure.Image
return ImageCache.TryGetValue(path, loadFullImage, out image);
}
public static async ValueTask<ImageSource> LoadAsync(string path, bool loadFullImage = false)
public static async ValueTask<ImageSource> LoadAsync(string path, bool loadFullImage = false, bool cacheImage = true)
{
var imageResult = await LoadInternalAsync(path, loadFullImage);
@ -293,22 +321,24 @@ namespace Flow.Launcher.Infrastructure.Image
// image already exists
img = ImageCache[key, loadFullImage] ?? img;
}
else
else if (cacheImage)
{
// new guid
// save guid key
GuidToKey[hash] = path;
}
}
// update cache
ImageCache[path, loadFullImage] = img;
if (cacheImage)
{
// update cache
ImageCache[path, loadFullImage] = img;
}
}
return img;
}
private static BitmapImage LoadFullImage(string path)
private static ImageSource LoadFullImage(string path)
{
BitmapImage image = new BitmapImage();
image.BeginInit();
@ -317,24 +347,24 @@ namespace Flow.Launcher.Infrastructure.Image
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
image.EndInit();
if (image.PixelWidth > 320)
if (image.PixelWidth > FullImageSize)
{
BitmapImage resizedWidth = new BitmapImage();
resizedWidth.BeginInit();
resizedWidth.CacheOption = BitmapCacheOption.OnLoad;
resizedWidth.UriSource = new Uri(path);
resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
resizedWidth.DecodePixelWidth = 320;
resizedWidth.DecodePixelWidth = FullImageSize;
resizedWidth.EndInit();
if (resizedWidth.PixelHeight > 320)
if (resizedWidth.PixelHeight > FullImageSize)
{
BitmapImage resizedHeight = new BitmapImage();
resizedHeight.BeginInit();
resizedHeight.CacheOption = BitmapCacheOption.OnLoad;
resizedHeight.UriSource = new Uri(path);
resizedHeight.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
resizedHeight.DecodePixelHeight = 320;
resizedHeight.DecodePixelHeight = FullImageSize;
resizedHeight.EndInit();
return resizedHeight;
}
@ -344,5 +374,50 @@ namespace Flow.Launcher.Infrastructure.Image
return image;
}
private static ImageSource LoadSvgImage(string path, bool loadFullImage = false)
{
// Set up drawing settings
var desiredHeight = loadFullImage ? FullImageSize : SmallIconSize;
var drawingSettings = new WpfDrawingSettings
{
IncludeRuntime = true,
// Set IgnoreRootViewbox to false to respect the SVG's viewBox
IgnoreRootViewbox = false
};
// Load and render the SVG
var converter = new FileSvgReader(drawingSettings);
var drawing = converter.Read(new Uri(path));
// Calculate scale to achieve desired height
var drawingBounds = drawing.Bounds;
if (drawingBounds.Height <= 0)
{
throw new InvalidOperationException($"Invalid SVG dimensions: Height must be greater than zero in {path}");
}
var scale = desiredHeight / drawingBounds.Height;
var scaledWidth = drawingBounds.Width * scale;
var scaledHeight = drawingBounds.Height * scale;
// Convert the Drawing to a Bitmap
var drawingVisual = new DrawingVisual();
using (DrawingContext drawingContext = drawingVisual.RenderOpen())
{
drawingContext.PushTransform(new ScaleTransform(scale, scale));
drawingContext.DrawDrawing(drawing);
}
// Create a RenderTargetBitmap to hold the rendered image
var bitmap = new RenderTargetBitmap(
(int)Math.Ceiling(scaledWidth),
(int)Math.Ceiling(scaledHeight),
96, // DpiX
96, // DpiY
PixelFormats.Pbgra32);
bitmap.Render(drawingVisual);
return bitmap;
}
}
}

View file

@ -12,7 +12,7 @@ using Windows.Win32.Graphics.Gdi;
namespace Flow.Launcher.Infrastructure.Image
{
/// <summary>
/// Subclass of <see cref="Windows.Win32.UI.Shell.SIIGBF"/>
/// Subclass of <see cref="SIIGBF"/>
/// </summary>
[Flags]
public enum ThumbnailOptions
@ -31,7 +31,9 @@ namespace Flow.Launcher.Infrastructure.Image
private static readonly Guid GUID_IShellItem = typeof(IShellItem).GUID;
private static readonly HRESULT S_ExtractionFailed = (HRESULT)0x8004B200;
private static readonly HRESULT S_EXTRACTIONFAILED = (HRESULT)0x8004B200;
private static readonly HRESULT S_PATHNOTFOUND = (HRESULT)0x8004B205;
public static BitmapSource GetThumbnail(string fileName, int width, int height, ThumbnailOptions options)
{
@ -79,9 +81,10 @@ namespace Flow.Launcher.Infrastructure.Image
{
imageFactory.GetImage(size, (SIIGBF)options, &hBitmap);
}
catch (COMException ex) when (ex.HResult == S_ExtractionFailed && options == ThumbnailOptions.ThumbnailOnly)
catch (COMException ex) when (options == ThumbnailOptions.ThumbnailOnly &&
(ex.HResult == S_PATHNOTFOUND || ex.HResult == S_EXTRACTIONFAILED))
{
// Fallback to IconOnly if ThumbnailOnly fails
// Fallback to IconOnly if extraction fails or files cannot be found
imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap);
}
catch (FileNotFoundException) when (options == ThumbnailOptions.ThumbnailOnly)
@ -89,6 +92,11 @@ namespace Flow.Launcher.Infrastructure.Image
// Fallback to IconOnly if files cannot be found
imageFactory.GetImage(size, (SIIGBF)ThumbnailOptions.IconOnly, &hBitmap);
}
catch (System.Exception ex)
{
// Handle other exceptions
throw new InvalidOperationException("Failed to get thumbnail", ex);
}
}
finally
{

View file

@ -1,24 +1,24 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
using Flow.Launcher.Infrastructure.UserSettings;
using NLog;
using NLog.Config;
using NLog.Targets;
using Flow.Launcher.Infrastructure.UserSettings;
using NLog.Targets.Wrappers;
using System.Runtime.ExceptionServices;
namespace Flow.Launcher.Infrastructure.Logger
{
public static class Log
{
public const string DirectoryName = "Logs";
public const string DirectoryName = Constant.Logs;
public static string CurrentLogDirectory { get; }
static Log()
{
CurrentLogDirectory = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Version);
CurrentLogDirectory = DataLocation.VersionLogDirectory;
if (!Directory.Exists(CurrentLogDirectory))
{
Directory.CreateDirectory(CurrentLogDirectory);
@ -48,17 +48,45 @@ namespace Flow.Launcher.Infrastructure.Logger
configuration.AddTarget("file", fileTargetASyncWrapper);
configuration.AddTarget("debug", debugTarget);
var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper)
{
RuleName = "file"
};
#if DEBUG
var fileRule = new LoggingRule("*", LogLevel.Debug, fileTargetASyncWrapper);
var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget);
var debugRule = new LoggingRule("*", LogLevel.Debug, debugTarget)
{
RuleName = "debug"
};
configuration.LoggingRules.Add(debugRule);
#else
var fileRule = new LoggingRule("*", LogLevel.Info, fileTargetASyncWrapper);
#endif
configuration.LoggingRules.Add(fileRule);
LogManager.Configuration = configuration;
}
public static void SetLogLevel(LOGLEVEL level)
{
switch (level)
{
case LOGLEVEL.DEBUG:
UseDebugLogLevel();
break;
default:
UseInfoLogLevel();
break;
}
Info(nameof(Logger), $"Using log level: {level}.");
}
private static void UseDebugLogLevel()
{
LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Debug, LogLevel.Fatal);
}
private static void UseInfoLogLevel()
{
LogManager.Configuration.FindRuleByName("file").SetLoggingLevels(LogLevel.Info, LogLevel.Fatal);
}
private static void LogFaultyFormat(string message)
{
var logger = LogManager.GetLogger("FaultyLogger");
@ -66,13 +94,6 @@ namespace Flow.Launcher.Infrastructure.Logger
logger.Fatal(message);
}
private static bool FormatValid(string message)
{
var parts = message.Split('|');
var valid = parts.Length == 3 && !string.IsNullOrWhiteSpace(parts[1]) && !string.IsNullOrWhiteSpace(parts[2]);
return valid;
}
public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "")
{
exception = exception.Demystify();
@ -107,57 +128,14 @@ namespace Flow.Launcher.Infrastructure.Logger
return className;
}
#if !DEBUG
private static void ExceptionInternal(string classAndMethod, string message, System.Exception e)
{
var logger = LogManager.GetLogger(classAndMethod);
logger.Error(e, message);
}
private static void LogInternal(string message, LogLevel level)
{
if (FormatValid(message))
{
var parts = message.Split('|');
var prefix = parts[1];
var unprefixed = parts[2];
var logger = LogManager.GetLogger(prefix);
logger.Log(level, unprefixed);
}
else
{
LogFaultyFormat(message);
}
}
/// Example: "|ClassName.MethodName|Message"
/// <param name="message">Example: "|ClassName.MethodName|Message" </param>
/// <param name="e">Exception</param>
public static void Exception(string message, System.Exception e)
{
e = e.Demystify();
#if DEBUG
ExceptionDispatchInfo.Capture(e).Throw();
#else
if (FormatValid(message))
{
var parts = message.Split('|');
var prefix = parts[1];
var unprefixed = parts[2];
ExceptionInternal(prefix, unprefixed, e);
}
else
{
LogFaultyFormat(message);
}
#endif
}
/// Example: "|ClassName.MethodName|Message"
public static void Error(string message)
{
LogInternal(message, LogLevel.Error);
}
public static void Error(string className, string message, [CallerMemberName] string methodName = "")
{
@ -178,32 +156,20 @@ namespace Flow.Launcher.Infrastructure.Logger
LogInternal(LogLevel.Debug, className, message, methodName);
}
/// Example: "|ClassName.MethodName|Message""
public static void Debug(string message)
{
LogInternal(message, LogLevel.Debug);
}
public static void Info(string className, string message, [CallerMemberName] string methodName = "")
{
LogInternal(LogLevel.Info, className, message, methodName);
}
/// Example: "|ClassName.MethodName|Message"
public static void Info(string message)
{
LogInternal(message, LogLevel.Info);
}
public static void Warn(string className, string message, [CallerMemberName] string methodName = "")
{
LogInternal(LogLevel.Warn, className, message, methodName);
}
}
/// Example: "|ClassName.MethodName|Message"
public static void Warn(string message)
{
LogInternal(message, LogLevel.Warn);
}
public enum LOGLEVEL
{
DEBUG,
INFO
}
}

View file

@ -0,0 +1,123 @@
using System.Collections.Generic;
using System;
using System.Runtime.InteropServices;
using System.Windows;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Flow.Launcher.Infrastructure;
/// <summary>
/// Contains full information about a display monitor.
/// Codes are edited from: <see href="https://github.com/Jack251970/DesktopWidgets3">.
/// </summary>
internal class MonitorInfo
{
/// <summary>
/// Gets the display monitors (including invisible pseudo-monitors associated with the mirroring drivers).
/// </summary>
/// <returns>A list of display monitors</returns>
public static unsafe IList<MonitorInfo> GetDisplayMonitors()
{
var monitorCount = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CMONITORS);
var list = new List<MonitorInfo>(monitorCount);
var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) =>
{
list.Add(new MonitorInfo(monitor, rect));
return true;
});
var dwData = new LPARAM();
var hdc = new HDC();
bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData);
if (!ok)
{
Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error());
}
return list;
}
/// <summary>
/// Gets the display monitor that is nearest to a given window.
/// </summary>
/// <param name="hwnd">Window handle</param>
/// <returns>The display monitor that is nearest to a given window, or null if no monitor is found.</returns>
public static unsafe MonitorInfo GetNearestDisplayMonitor(HWND hwnd)
{
var nearestMonitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST);
MonitorInfo nearestMonitorInfo = null;
var callback = new MONITORENUMPROC((HMONITOR monitor, HDC deviceContext, RECT* rect, LPARAM data) =>
{
if (monitor == nearestMonitor)
{
nearestMonitorInfo = new MonitorInfo(monitor, rect);
return false;
}
return true;
});
var dwData = new LPARAM();
var hdc = new HDC();
bool ok = PInvoke.EnumDisplayMonitors(hdc, (RECT?)null, callback, dwData);
if (!ok)
{
Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error());
}
return nearestMonitorInfo;
}
private readonly HMONITOR _monitor;
internal unsafe MonitorInfo(HMONITOR monitor, RECT* rect)
{
RectMonitor =
new Rect(new Point(rect->left, rect->top),
new Point(rect->right, rect->bottom));
_monitor = monitor;
var info = new MONITORINFOEXW() { monitorInfo = new MONITORINFO() { cbSize = (uint)sizeof(MONITORINFOEXW) } };
GetMonitorInfo(monitor, ref info);
RectWork =
new Rect(new Point(info.monitorInfo.rcWork.left, info.monitorInfo.rcWork.top),
new Point(info.monitorInfo.rcWork.right, info.monitorInfo.rcWork.bottom));
Name = new string(info.szDevice.AsSpan()).Replace("\0", "").Trim();
}
/// <summary>
/// Gets the name of the display.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the display monitor rectangle, expressed in virtual-screen coordinates.
/// </summary>
/// <remarks>
/// <note>If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values.</note>
/// </remarks>
public Rect RectMonitor { get; }
/// <summary>
/// Gets the work area rectangle of the display monitor, expressed in virtual-screen coordinates.
/// </summary>
/// <remarks>
/// <note>If the monitor is not the primary display monitor, some of the rectangle's coordinates may be negative values.</note>
/// </remarks>
public Rect RectWork { get; }
/// <summary>
/// Gets if the monitor is the the primary display monitor.
/// </summary>
public bool IsPrimary => _monitor == PInvoke.MonitorFromWindow(new(IntPtr.Zero), MONITOR_FROM_FLAGS.MONITOR_DEFAULTTOPRIMARY);
/// <inheritdoc />
public override string ToString() => $"{Name} {RectMonitor.Width}x{RectMonitor.Height}";
private static unsafe bool GetMonitorInfo(HMONITOR hMonitor, ref MONITORINFOEXW lpmi)
{
fixed (MONITORINFOEXW* lpmiLocal = &lpmi)
{
var lpmiBase = (MONITORINFO*)lpmiLocal;
var __result = PInvoke.GetMonitorInfo(hMonitor, lpmiBase);
return __result;
}
}
}

View file

@ -11,9 +11,58 @@ GetModuleHandle
GetKeyState
VIRTUAL_KEY
WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP
EnumWindows
EnumWindows
DwmSetWindowAttribute
DWM_SYSTEMBACKDROP_TYPE
DWM_WINDOW_CORNER_PREFERENCE
MAX_PATH
SystemParametersInfo
SetForegroundWindow
WINDOW_LONG_PTR_INDEX
GetForegroundWindow
GetDesktopWindow
GetShellWindow
GetWindowRect
GetClassName
FindWindowEx
WINDOW_STYLE
SetLastError
WINDOW_EX_STYLE
GetSystemMetrics
EnumDisplayMonitors
MonitorFromWindow
GetMonitorInfo
MONITORINFOEXW
WM_ENTERSIZEMOVE
WM_EXITSIZEMOVE
WM_NCLBUTTONDBLCLK
WM_SYSCOMMAND
SC_MAXIMIZE
SC_MINIMIZE
OleInitialize
OleUninitialize
GetKeyboardLayout
GetWindowThreadProcessId
ActivateKeyboardLayout
GetKeyboardLayoutList
PostMessage
WM_INPUTLANGCHANGEREQUEST
INPUTLANGCHANGE_FORWARD
LOCALE_TRANSIENT_KEYBOARD1
LOCALE_TRANSIENT_KEYBOARD2
LOCALE_TRANSIENT_KEYBOARD3
LOCALE_TRANSIENT_KEYBOARD4
SHParseDisplayName
SHOpenFolderAndSelectItems
CoTaskMemFree

View file

@ -0,0 +1,45 @@
using System.Runtime.InteropServices;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Windows.Win32;
internal static partial class PInvoke
{
// SetWindowLong
// Edited from: https://github.com/files-community/Files
[DllImport("User32", EntryPoint = "SetWindowLongW", ExactSpelling = true)]
private static extern int _SetWindowLong(HWND hWnd, int nIndex, int dwNewLong);
[DllImport("User32", EntryPoint = "SetWindowLongPtrW", ExactSpelling = true)]
private static extern nint _SetWindowLongPtr(HWND hWnd, int nIndex, nint dwNewLong);
// NOTE:
// CsWin32 doesn't generate SetWindowLong on other than x86 and vice versa.
// For more info, visit https://github.com/microsoft/CsWin32/issues/882
public static unsafe nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong)
{
return sizeof(nint) is 4
? _SetWindowLong(hWnd, (int)nIndex, (int)dwNewLong)
: _SetWindowLongPtr(hWnd, (int)nIndex, dwNewLong);
}
// GetWindowLong
[DllImport("User32", EntryPoint = "GetWindowLongW", ExactSpelling = true)]
private static extern int _GetWindowLong(HWND hWnd, int nIndex);
[DllImport("User32", EntryPoint = "GetWindowLongPtrW", ExactSpelling = true)]
private static extern nint _GetWindowLongPtr(HWND hWnd, int nIndex);
// NOTE:
// CsWin32 doesn't generate GetWindowLong on other than x86 and vice versa.
// For more info, visit https://github.com/microsoft/CsWin32/issues/882
public static unsafe nint GetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex)
{
return sizeof(nint) is 4
? _GetWindowLong(hWnd, (int)nIndex)
: _GetWindowLongPtr(hWnd, (int)nIndex);
}
}

View file

@ -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<int> originalIndexs = new List<int>();
private List<int> translatedIndexs = new List<int>();
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;
}
}
/// <summary>
/// Translate a language to English letters using a given rule.
/// </summary>
public interface IAlphabet
{
/// <summary>
/// Translate a string to English letters, using a given rule.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public (string translation, TranslationMapping map) Translate(string stringToTranslate);
/// <summary>
/// Determine if a string can be translated to English letter with this Alphabet.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public bool CanBeTranslated(string stringToTranslate);
}
public class PinyinAlphabet : IAlphabet
{
private ConcurrentDictionary<string, (string translation, TranslationMapping map)> _pinyinCache =
new ConcurrentDictionary<string, (string translation, TranslationMapping map)>();
private Settings _settings;
private readonly ConcurrentDictionary<string, (string translation, TranslationMapping map)> _pinyinCache = new();
private readonly Settings _settings;
private ReadOnlyDictionary<string, string> currentDoublePinyinTable;
public PinyinAlphabet()
{
Initialize(Ioc.Default.GetRequiredService<Settings>());
_settings = Ioc.Default.GetRequiredService<Settings>();
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<Dictionary<string, Dictionary<string, string>>>(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<string, string>(schemaDict);
}
private void LoadDoublePinyinTable()
{
if (!_settings.UseDoublePinyin)
{
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
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<string, string>(new Dictionary<string, string>());
}
catch (DirectoryNotFoundException e)
{
Log.Exception(nameof(PinyinAlphabet), $"Directory not found for double pinyin table: {tablePath}", e);
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
catch (UnauthorizedAccessException e)
{
Log.Exception(nameof(PinyinAlphabet), $"Access denied to double pinyin table: {tablePath}", e);
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
catch (System.Exception e)
{
Log.Exception(nameof(PinyinAlphabet), $"Failed to load double pinyin table from file: {tablePath}", e);
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
}
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;
}
/// <summary>
/// Optimized Chinese character detection using the comprehensive CJK Unicode ranges
/// </summary>
private static bool ContainsChinese(ReadOnlySpan<char> text)
{
foreach (var c in text)
{
return (content, null);
if (IsChineseCharacter(c))
return true;
}
return false;
}
/// <summary>
/// Check if a character is a Chinese character using comprehensive Unicode ranges
/// Covers CJK Unified Ideographs, Extension A
/// </summary>
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;
}
}
}

View file

@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
@ -7,91 +7,54 @@ namespace Flow.Launcher.Infrastructure
{
public static class Stopwatch
{
private static readonly Dictionary<string, long> Count = new Dictionary<string, long>();
private static readonly object Locker = new object();
/// <summary>
/// This stopwatch will appear only in Debug mode
/// </summary>
public static long Debug(string message, Action action)
public static long Debug(string className, string message, Action action, [CallerMemberName] string methodName = "")
{
var stopWatch = new System.Diagnostics.Stopwatch();
stopWatch.Start();
action();
stopWatch.Stop();
var milliseconds = stopWatch.ElapsedMilliseconds;
string info = $"{message} <{milliseconds}ms>";
Log.Debug(info);
Log.Debug(className, $"{message} <{milliseconds}ms>", methodName);
return milliseconds;
}
/// <summary>
/// This stopwatch will appear only in Debug mode
/// </summary>
public static async Task<long> DebugAsync(string message, Func<Task> action)
public static async Task<long> DebugAsync(string className, string message, Func<Task> action, [CallerMemberName] string methodName = "")
{
var stopWatch = new System.Diagnostics.Stopwatch();
stopWatch.Start();
await action();
stopWatch.Stop();
var milliseconds = stopWatch.ElapsedMilliseconds;
string info = $"{message} <{milliseconds}ms>";
Log.Debug(info);
Log.Debug(className, $"{message} <{milliseconds}ms>", methodName);
return milliseconds;
}
public static long Normal(string message, Action action)
public static long Info(string className, string message, Action action, [CallerMemberName] string methodName = "")
{
var stopWatch = new System.Diagnostics.Stopwatch();
stopWatch.Start();
action();
stopWatch.Stop();
var milliseconds = stopWatch.ElapsedMilliseconds;
string info = $"{message} <{milliseconds}ms>";
Log.Info(info);
Log.Info(className, $"{message} <{milliseconds}ms>", methodName);
return milliseconds;
}
public static async Task<long> NormalAsync(string message, Func<Task> action)
public static async Task<long> InfoAsync(string className, string message, Func<Task> action, [CallerMemberName] string methodName = "")
{
var stopWatch = new System.Diagnostics.Stopwatch();
stopWatch.Start();
await action();
stopWatch.Stop();
var milliseconds = stopWatch.ElapsedMilliseconds;
string info = $"{message} <{milliseconds}ms>";
Log.Info(info);
Log.Info(className, $"{message} <{milliseconds}ms>", methodName);
return milliseconds;
}
public static void StartCount(string name, Action action)
{
var stopWatch = new System.Diagnostics.Stopwatch();
stopWatch.Start();
action();
stopWatch.Stop();
var milliseconds = stopWatch.ElapsedMilliseconds;
lock (Locker)
{
if (Count.ContainsKey(name))
{
Count[name] += milliseconds;
}
else
{
Count[name] = 0;
}
}
}
public static void EndCount()
{
foreach (var key in Count.Keys)
{
string info = $"{key} already cost {Count[key]}ms";
Log.Debug(info);
}
}
}
}

View file

@ -1,14 +1,14 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters;
using System.Runtime.Serialization.Formatters.Binary;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using MemoryPack;
#nullable enable
namespace Flow.Launcher.Infrastructure.Storage
{
/// <summary>
@ -16,64 +16,111 @@ namespace Flow.Launcher.Infrastructure.Storage
/// Normally, it has better performance, but not readable
/// </summary>
/// <remarks>
/// It utilize MemoryPack, which means the object must be MemoryPackSerializable
/// https://github.com/Cysharp/MemoryPack
/// It utilizes MemoryPack, which means the object must be MemoryPackSerializable <see href="https://github.com/Cysharp/MemoryPack"/>
/// </remarks>
public class BinaryStorage<T>
public class BinaryStorage<T> : ISavable
{
const string DirectoryName = "Cache";
private static readonly string ClassName = "BinaryStorage";
const string FileSuffix = ".cache";
protected T? Data;
public const string FileSuffix = ".cache";
protected string FilePath { get; init; } = null!;
protected string DirectoryPath { get; init; } = null!;
// Let the derived class to set the file path
protected BinaryStorage()
{
}
public BinaryStorage(string filename)
{
var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName);
Helper.ValidateDirectory(directoryPath);
DirectoryPath = DataLocation.CacheDirectory;
FilesFolders.ValidateDirectory(DirectoryPath);
FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}");
FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}");
}
public string FilePath { get; }
// Let the old Program plugin get this constructor
[Obsolete("This constructor is obsolete. Use BinaryStorage(string filename) instead.")]
public BinaryStorage(string filename, string directoryPath = null!)
{
DirectoryPath = directoryPath ?? DataLocation.CacheDirectory;
FilesFolders.ValidateDirectory(DirectoryPath);
FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}");
}
public async ValueTask<T> TryLoadAsync(T defaultData)
{
if (Data != null) return Data;
if (File.Exists(FilePath))
{
if (new FileInfo(FilePath).Length == 0)
{
Log.Error($"|BinaryStorage.TryLoad|Zero length cache file <{FilePath}>");
await SaveAsync(defaultData);
return defaultData;
Log.Error(ClassName, $"Zero length cache file <{FilePath}>");
Data = defaultData;
await SaveAsync();
}
await using var stream = new FileStream(FilePath, FileMode.Open);
var d = await DeserializeAsync(stream, defaultData);
return d;
Data = await DeserializeAsync(stream, defaultData);
}
else
{
Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data");
await SaveAsync(defaultData);
return defaultData;
Log.Info(ClassName, "Cache file not exist, load default data");
Data = defaultData;
await SaveAsync();
}
return Data;
}
private async ValueTask<T> DeserializeAsync(Stream stream, T defaultData)
private static async ValueTask<T> DeserializeAsync(Stream stream, T defaultData)
{
try
{
var t = await MemoryPackSerializer.DeserializeAsync<T>(stream);
return t;
return t ?? defaultData;
}
catch (System.Exception e)
catch (System.Exception)
{
// Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e);
return defaultData;
}
}
public void Save()
{
// User may delete the directory, so we need to check it
FilesFolders.ValidateDirectory(DirectoryPath);
var serialized = MemoryPackSerializer.Serialize(Data);
File.WriteAllBytes(FilePath, serialized);
}
public async ValueTask SaveAsync()
{
await SaveAsync(Data.NonNull());
}
// ImageCache need to convert data into concurrent dictionary for usage,
// so we would better to clear the data
public void ClearData()
{
Data = default;
}
// ImageCache storages data in its class,
// so we need to pass it to SaveAsync
public async ValueTask SaveAsync(T data)
{
// User may delete the directory, so we need to check it
FilesFolders.ValidateDirectory(DirectoryPath);
await using var stream = new FileStream(FilePath, FileMode.Create);
await MemoryPackSerializer.SerializeAsync(stream, data);
}

View file

@ -1,17 +1,48 @@
using System.IO;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
namespace Flow.Launcher.Infrastructure.Storage
{
public class FlowLauncherJsonStorage<T> : JsonStorage<T> where T : new()
// Expose ISaveable interface in derived class to make sure we are calling the new version of Save method
public class FlowLauncherJsonStorage<T> : JsonStorage<T>, ISavable where T : new()
{
private static readonly string ClassName = "FlowLauncherJsonStorage";
public FlowLauncherJsonStorage()
{
var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName);
Helper.ValidateDirectory(directoryPath);
DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName);
FilesFolders.ValidateDirectory(DirectoryPath);
var filename = typeof(T).Name;
FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}");
FilePath = Path.Combine(DirectoryPath, $"{filename}{FileSuffix}");
}
public new void Save()
{
try
{
base.Save();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e);
}
}
public new async Task SaveAsync()
{
try
{
await base.SaveAsync();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save FL settings to path: {FilePath}", e);
}
}
}
}
}

View file

@ -1,22 +1,27 @@
#nullable enable
using System;
using System;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
#nullable enable
namespace Flow.Launcher.Infrastructure.Storage
{
/// <summary>
/// Serialize object using json format.
/// </summary>
public class JsonStorage<T> where T : new()
public class JsonStorage<T> : ISavable where T : new()
{
private static readonly string ClassName = "JsonStorage";
protected T? Data;
// need a new directory name
public const string DirectoryName = "Settings";
public const string DirectoryName = Constant.Settings;
public const string FileSuffix = ".json";
protected string FilePath { get; init; } = null!;
@ -37,7 +42,23 @@ namespace Flow.Launcher.Infrastructure.Storage
FilePath = filePath;
DirectoryPath = Path.GetDirectoryName(filePath) ?? throw new ArgumentException("Invalid file path");
Helper.ValidateDirectory(DirectoryPath);
FilesFolders.ValidateDirectory(DirectoryPath);
}
public bool Exists()
{
return File.Exists(FilePath);
}
public void Delete()
{
foreach (var path in new[] { FilePath, BackupFilePath, TempFilePath })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
public async Task<T> LoadAsync()
@ -101,7 +122,7 @@ namespace Flow.Launcher.Infrastructure.Storage
private void RestoreBackup()
{
Log.Info($"|JsonStorage.Load|Failed to load settings.json, {BackupFilePath} restored successfully");
Log.Info(ClassName, $"Failed to load settings.json, {BackupFilePath} restored successfully");
if (File.Exists(FilePath))
File.Replace(BackupFilePath, FilePath, null);
@ -180,7 +201,10 @@ namespace Flow.Launcher.Infrastructure.Storage
public void Save()
{
string serialized = JsonSerializer.Serialize(Data,
// User may delete the directory, so we need to check it
FilesFolders.ValidateDirectory(DirectoryPath);
var serialized = JsonSerializer.Serialize(Data,
new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(TempFilePath, serialized);
@ -190,6 +214,9 @@ namespace Flow.Launcher.Infrastructure.Storage
public async Task SaveAsync()
{
// User may delete the directory, so we need to check it
FilesFolders.ValidateDirectory(DirectoryPath);
await using var tempOutput = File.OpenWrite(TempFilePath);
await JsonSerializer.SerializeAsync(tempOutput, Data,
new JsonSerializerOptions { WriteIndented = true });

View file

@ -0,0 +1,46 @@
using System.IO;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
namespace Flow.Launcher.Infrastructure.Storage
{
// Expose ISaveable interface in derived class to make sure we are calling the new version of Save method
public class PluginBinaryStorage<T> : BinaryStorage<T>, ISavable where T : new()
{
private static readonly string ClassName = "PluginBinaryStorage";
public PluginBinaryStorage(string cacheName, string cacheDirectory)
{
DirectoryPath = cacheDirectory;
FilesFolders.ValidateDirectory(DirectoryPath);
FilePath = Path.Combine(DirectoryPath, $"{cacheName}{FileSuffix}");
}
public new void Save()
{
try
{
base.Save();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e);
}
}
public new async Task SaveAsync()
{
try
{
await base.SaveAsync();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save plugin caches to path: {FilePath}", e);
}
}
}
}

View file

@ -1,20 +1,27 @@
using System.IO;
using System.Threading.Tasks;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
namespace Flow.Launcher.Infrastructure.Storage
{
public class PluginJsonStorage<T> : JsonStorage<T> where T : new()
// Expose ISaveable interface in derived class to make sure we are calling the new version of Save method
public class PluginJsonStorage<T> : JsonStorage<T>, ISavable where T : new()
{
// Use assembly name to check which plugin is using this storage
public readonly string AssemblyName;
private static readonly string ClassName = "PluginJsonStorage";
public PluginJsonStorage()
{
// C# related, add python related below
var dataType = typeof(T);
AssemblyName = dataType.Assembly.GetName().Name;
DirectoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName, Constant.Plugins, AssemblyName);
Helper.ValidateDirectory(DirectoryPath);
DirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, AssemblyName);
FilesFolders.ValidateDirectory(DirectoryPath);
FilePath = Path.Combine(DirectoryPath, $"{dataType.Name}{FileSuffix}");
}
@ -24,11 +31,27 @@ namespace Flow.Launcher.Infrastructure.Storage
Data = data;
}
public void DeleteDirectory()
public new void Save()
{
if (Directory.Exists(DirectoryPath))
try
{
Directory.Delete(DirectoryPath, true);
base.Save();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e);
}
}
public new async Task SaveAsync()
{
try
{
await base.SaveAsync();
}
catch (System.Exception e)
{
Log.Exception(ClassName, $"Failed to save plugin settings to path: {FilePath}", e);
}
}
}

View file

@ -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;

View file

@ -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<int> _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;
}
}
}

View file

@ -3,6 +3,7 @@ using System.Windows.Markup;
namespace Flow.Launcher.Infrastructure.UI
{
[Obsolete("EnumBindingSourceExtension is obsolete. Use with Flow.Launcher.Localization NuGet package instead.")]
public class EnumBindingSourceExtension : MarkupExtension
{
private Type _enumType;

View file

@ -1,15 +1,17 @@
using System;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Infrastructure.UserSettings
{
#region Base
public abstract class ShortcutBaseModel
{
public string Key { get; set; }
[JsonIgnore]
public Func<string> Expand { get; set; } = () => { return ""; };
public override bool Equals(object obj)
{
return obj is ShortcutBaseModel other &&
@ -22,16 +24,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
public class CustomShortcutModel : ShortcutBaseModel
public class BaseCustomShortcutModel : ShortcutBaseModel
{
public string Value { get; set; }
[JsonConstructorAttribute]
public CustomShortcutModel(string key, string value)
public BaseCustomShortcutModel(string key, string value)
{
Key = key;
Value = value;
Expand = () => { return Value; };
}
public void Deconstruct(out string key, out string value)
@ -40,26 +40,75 @@ namespace Flow.Launcher.Infrastructure.UserSettings
value = Value;
}
public static implicit operator (string Key, string Value)(CustomShortcutModel shortcut)
public static implicit operator (string Key, string Value)(BaseCustomShortcutModel shortcut)
{
return (shortcut.Key, shortcut.Value);
}
public static implicit operator CustomShortcutModel((string Key, string Value) shortcut)
public static implicit operator BaseCustomShortcutModel((string Key, string Value) shortcut)
{
return new CustomShortcutModel(shortcut.Key, shortcut.Value);
return new BaseCustomShortcutModel(shortcut.Key, shortcut.Value);
}
}
public class BuiltinShortcutModel : ShortcutBaseModel
public class BaseBuiltinShortcutModel : ShortcutBaseModel
{
public string Description { get; set; }
public BuiltinShortcutModel(string key, string description, Func<string> expand)
public string LocalizedDescription => API.GetTranslation(Description);
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
public BaseBuiltinShortcutModel(string key, string description)
{
Key = key;
Description = description;
Expand = expand ?? (() => { return ""; });
}
}
#endregion
#region Custom Shortcut
public class CustomShortcutModel : BaseCustomShortcutModel
{
[JsonIgnore]
public Func<string> Expand { get; set; } = () => { return string.Empty; };
[JsonConstructor]
public CustomShortcutModel(string key, string value) : base(key, value)
{
Expand = () => { return Value; };
}
}
#endregion
#region Builtin Shortcut
public class BuiltinShortcutModel : BaseBuiltinShortcutModel
{
[JsonIgnore]
public Func<string> Expand { get; set; } = () => { return string.Empty; };
public BuiltinShortcutModel(string key, string description, Func<string> expand) : base(key, description)
{
Expand = expand ?? (() => { return string.Empty; });
}
}
public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel
{
[JsonIgnore]
public Func<Task<string>> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); };
public AsyncBuiltinShortcutModel(string key, string description, Func<Task<string>> expandAsync) : base(key, description)
{
ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); });
}
}
#endregion
}

View file

@ -25,8 +25,16 @@ namespace Flow.Launcher.Infrastructure.UserSettings
return false;
}
public static string VersionLogDirectory => Path.Combine(LogDirectory, Constant.Version);
public static string LogDirectory => Path.Combine(DataDirectory(), Constant.Logs);
public static readonly string CacheDirectory = Path.Combine(DataDirectory(), Constant.Cache);
public static readonly string SettingsDirectory = Path.Combine(DataDirectory(), Constant.Settings);
public static readonly string PluginsDirectory = Path.Combine(DataDirectory(), Constant.Plugins);
public static readonly string PluginSettingsDirectory = Path.Combine(DataDirectory(), "Settings", Constant.Plugins);
public static readonly string ThemesDirectory = Path.Combine(DataDirectory(), Constant.Themes);
public static readonly string PluginSettingsDirectory = Path.Combine(SettingsDirectory, Constant.Plugins);
public static readonly string PluginCacheDirectory = Path.Combine(DataDirectory(), Constant.Cache, Constant.Plugins);
public const string PythonEnvironmentName = "Python";
public const string NodeEnvironmentName = "Node.js";

View file

@ -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);
}
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Infrastructure.UserSettings
@ -6,8 +7,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public class PluginsSettings : BaseModel
{
private string pythonExecutablePath = string.Empty;
public string PythonExecutablePath {
get { return pythonExecutablePath; }
public string PythonExecutablePath
{
get => pythonExecutablePath;
set
{
pythonExecutablePath = value;
@ -18,7 +20,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
private string nodeExecutablePath = string.Empty;
public string NodeExecutablePath
{
get { return nodeExecutablePath; }
get => nodeExecutablePath;
set
{
nodeExecutablePath = value;
@ -26,19 +28,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
public Dictionary<string, Plugin> Plugins { get; set; } = new Dictionary<string, Plugin>();
/// <summary>
/// Only used for serialization
/// </summary>
public Dictionary<string, Plugin> Plugins { get; set; } = new();
/// <summary>
/// Update plugin settings with metadata.
/// FL will get default values from metadata first and then load settings to metadata
/// </summary>
/// <param name="metadatas">Parsed plugin metadatas</param>
public void UpdatePluginSettings(List<PluginMetadata> metadatas)
{
foreach (var metadata in metadatas)
{
if (Plugins.ContainsKey(metadata.ID))
if (Plugins.TryGetValue(metadata.ID, out var settings))
{
var settings = Plugins[metadata.ID];
// If settings exist, update settings & metadata value
// update settings values with metadata
if (string.IsNullOrEmpty(settings.Version))
{
settings.Version = metadata.Version;
}
settings.DefaultActionKeywords = metadata.ActionKeywords; // metadata provides default values
settings.DefaultSearchDelayTime = metadata.SearchDelayTime; // metadata provides default values
// update metadata values with settings
if (settings.ActionKeywords?.Count > 0)
{
metadata.ActionKeywords = settings.ActionKeywords;
@ -51,33 +66,70 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
metadata.Disabled = settings.Disabled;
metadata.Priority = settings.Priority;
metadata.SearchDelayTime = settings.SearchDelayTime;
metadata.HomeDisabled = settings.HomeDisabled;
}
else
{
// If settings does not exist, create a new one
Plugins[metadata.ID] = new Plugin
{
ID = metadata.ID,
Name = metadata.Name,
Version = metadata.Version,
ActionKeywords = metadata.ActionKeywords,
DefaultActionKeywords = metadata.ActionKeywords, // metadata provides default values
ActionKeywords = metadata.ActionKeywords, // use default value
Disabled = metadata.Disabled,
Priority = metadata.Priority
HomeDisabled = metadata.HomeDisabled,
Priority = metadata.Priority,
DefaultSearchDelayTime = metadata.SearchDelayTime, // metadata provides default values
SearchDelayTime = metadata.SearchDelayTime, // use default value
};
}
}
}
public Plugin GetPluginSettings(string id)
{
if (Plugins.TryGetValue(id, out var plugin))
{
return plugin;
}
return null;
}
public Plugin RemovePluginSettings(string id)
{
Plugins.Remove(id, out var plugin);
return plugin;
}
}
public class Plugin
{
public string ID { get; set; }
public string Name { get; set; }
public string Version { get; set; }
public List<string> ActionKeywords { get; set; } // a reference of the action keywords from plugin manager
[JsonIgnore]
public List<string> DefaultActionKeywords { get; set; }
// a reference of the action keywords from plugin manager
public List<string> ActionKeywords { get; set; }
public int Priority { get; set; }
[JsonIgnore]
public int? DefaultSearchDelayTime { get; set; }
public int? SearchDelayTime { get; set; }
/// <summary>
/// Used only to save the state of the plugin in settings
/// </summary>
public bool Disabled { get; set; }
public bool HomeDisabled { get; set; }
}
}

View file

@ -1,10 +1,11 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Text.Json.Serialization;
using System.Windows;
using System.Windows.Media;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.Storage;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedModels;
@ -24,7 +25,13 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public void Initialize()
{
// Initialize dependency injection instances after Ioc.Default is created
_stringMatcher = Ioc.Default.GetRequiredService<StringMatcher>();
// 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()
@ -32,12 +39,38 @@ namespace Flow.Launcher.Infrastructure.UserSettings
_storage.Save();
}
private string language = Constant.SystemLanguageCode;
private string _theme = Constant.DefaultTheme;
public string Hotkey { get; set; } = $"{KeyConstant.Alt} + {KeyConstant.Space}";
public string OpenResultModifiers { get; set; } = KeyConstant.Alt;
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";
@ -50,47 +83,56 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public string SelectPrevPageHotkey { get; set; } = $"PageDown";
public string OpenContextMenuHotkey { get; set; } = $"Ctrl+O";
public string SettingWindowHotkey { get; set; } = $"Ctrl+I";
public string OpenHistoryHotkey { get; set; } = $"Ctrl+H";
public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up";
public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down";
private string _language = Constant.SystemLanguageCode;
public string Language
{
get => language;
get => _language;
set
{
language = value;
OnPropertyChanged();
if (_language != value)
{
_language = value;
OnPropertyChanged();
}
}
}
private string _theme = Constant.DefaultTheme;
public string Theme
{
get => _theme;
set
{
if (value == _theme)
return;
_theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(MaxResultsToShow));
if (_theme != value)
{
_theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(MaxResultsToShow));
}
}
}
public bool UseDropShadowEffect { get; set; } = true;
public BackdropTypes BackdropType { get; set; } = BackdropTypes.None;
public string ReleaseNotesVersion { get; set; } = string.Empty;
/* Appearance Settings. It should be separated from the setting later.*/
public double WindowHeightSize { get; set; } = 42;
public double ItemHeightSize { get; set; } = 58;
public double QueryBoxFontSize { get; set; } = 20;
public double QueryBoxFontSize { get; set; } = 16;
public double ResultItemFontSize { get; set; } = 16;
public double ResultSubItemFontSize { get; set; } = 13;
public string QueryBoxFont { get; set; } = FontFamily.GenericSansSerif.Name;
public string QueryBoxFont { get; set; } = Win32Helper.GetSystemDefaultFont();
public string QueryBoxFontStyle { get; set; }
public string QueryBoxFontWeight { get; set; }
public string QueryBoxFontStretch { get; set; }
public string ResultFont { get; set; } = FontFamily.GenericSansSerif.Name;
public string ResultFont { get; set; } = Win32Helper.GetSystemDefaultFont();
public string ResultFontStyle { get; set; }
public string ResultFontWeight { get; set; }
public string ResultFontStretch { get; set; }
public string ResultSubFont { get; set; } = FontFamily.GenericSansSerif.Name;
public string ResultSubFont { get; set; } = Win32Helper.GetSystemDefaultFont();
public string ResultSubFontStyle { get; set; }
public string ResultSubFontWeight { get; set; }
public string ResultSubFontStretch { get; set; }
@ -98,6 +140,27 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public bool UseAnimation { get; set; } = true;
public bool UseSound { get; set; } = true;
public double SoundVolume { get; set; } = 50;
public bool ShowBadges { get; set; } = false;
public bool ShowBadgesGlobalOnly { get; set; } = false;
private string _settingWindowFont { get; set; } = Win32Helper.GetSystemDefaultFont(false);
public string SettingWindowFont
{
get => _settingWindowFont;
set
{
if (_settingWindowFont != value)
{
_settingWindowFont = value;
OnPropertyChanged();
if (Application.Current != null)
{
Application.Current.Resources["SettingWindowFont"] = new FontFamily(value);
Application.Current.Resources["ContentControlThemeFontFamily"] = new FontFamily(value);
}
}
}
}
public bool UseClock { get; set; } = true;
public bool UseDate { get; set; } = false;
@ -109,7 +172,68 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public double SettingWindowHeight { get; set; } = 700;
public double? SettingWindowTop { get; set; } = null;
public double? SettingWindowLeft { get; set; } = null;
public System.Windows.WindowState SettingWindowState { get; set; } = WindowState.Normal;
public WindowState SettingWindowState { get; set; } = WindowState.Normal;
private bool _showPlaceholder { get; set; } = true;
public bool ShowPlaceholder
{
get => _showPlaceholder;
set
{
if (_showPlaceholder != value)
{
_showPlaceholder = value;
OnPropertyChanged();
}
}
}
private string _placeholderText { get; set; } = string.Empty;
public string PlaceholderText
{
get => _placeholderText;
set
{
if (_placeholderText != value)
{
_placeholderText = value;
OnPropertyChanged();
}
}
}
private bool _showHomePage { get; set; } = true;
public bool ShowHomePage
{
get => _showHomePage;
set
{
if (_showHomePage != value)
{
_showHomePage = value;
OnPropertyChanged();
}
}
}
private bool _showHistoryResultsForHomePage = false;
public bool ShowHistoryResultsForHomePage
{
get => _showHistoryResultsForHomePage;
set
{
if (_showHistoryResultsForHomePage != value)
{
_showHistoryResultsForHomePage = value;
OnPropertyChanged();
}
}
}
public int MaxHistoryResultsToShowForHomePage { get; set; } = 5;
public bool AutoRestartAfterChanging { get; set; } = false;
public bool ShowUnknownSourceWarning { get; set; } = true;
public bool AutoUpdatePlugins { get; set; } = true;
public int CustomExplorerIndex { get; set; } = 0;
@ -148,8 +272,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings
new()
{
Name = "Files",
Path = "Files",
DirectoryArgument = "-select \"%d\"",
Path = "Files-Stable",
DirectoryArgument = "\"%d\"",
FileArgument = "-select \"%f\""
}
};
@ -199,10 +323,55 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
};
[JsonConverter(typeof(JsonStringEnumConverter))]
public LOGLEVEL LogLevel { get; set; } = LOGLEVEL.INFO;
/// <summary>
/// when false Alphabet static service will always return empty results
/// </summary>
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;
@ -215,9 +384,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;
}
}
}
@ -225,6 +397,10 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public double WindowLeft { get; set; }
public double WindowTop { get; set; }
public double PreviousScreenWidth { get; set; }
public double PreviousScreenHeight { get; set; }
public double PreviousDpiX { get; set; }
public double PreviousDpiY { get; set; }
/// <summary>
/// Custom left position on selected monitor
@ -236,19 +412,35 @@ namespace Flow.Launcher.Infrastructure.UserSettings
/// </summary>
public double CustomWindowTop { get; set; } = 0;
public bool KeepMaxResults { get; set; } = false;
public int MaxResultsToShow { get; set; } = 5;
public int ActivateTimes { get; set; }
/// <summary>
/// Fixed window size
/// </summary>
private bool _keepMaxResults { get; set; } = false;
public bool KeepMaxResults
{
get => _keepMaxResults;
set
{
if (_keepMaxResults != value)
{
_keepMaxResults = value;
OnPropertyChanged();
}
}
}
public int MaxResultsToShow { get; set; } = 5;
public int ActivateTimes { get; set; }
public ObservableCollection<CustomPluginHotkey> CustomPluginHotkeys { get; set; } = new ObservableCollection<CustomPluginHotkey>();
public ObservableCollection<CustomShortcutModel> CustomShortcuts { get; set; } = new ObservableCollection<CustomShortcutModel>();
[JsonIgnore]
public ObservableCollection<BuiltinShortcutModel> BuiltinShortcuts { get; set; } = new()
public ObservableCollection<BaseBuiltinShortcutModel> BuiltinShortcuts { get; set; } = new()
{
new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText),
new AsyncBuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", () => Win32Helper.StartSTATaskAsync(Clipboard.GetText)),
new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath)
};
@ -258,19 +450,39 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public bool StartFlowLauncherOnSystemStartup { get; set; } = false;
public bool UseLogonTaskForStartup { get; set; } = false;
public bool HideOnStartup { get; set; } = true;
bool _hideNotifyIcon { get; set; }
private bool _hideNotifyIcon;
public bool HideNotifyIcon
{
get { return _hideNotifyIcon; }
get => _hideNotifyIcon;
set
{
_hideNotifyIcon = value;
OnPropertyChanged();
if (_hideNotifyIcon != value)
{
_hideNotifyIcon = value;
OnPropertyChanged();
}
}
}
public bool LeaveCmdOpen { get; set; }
public bool HideWhenDeactivated { get; set; } = true;
private bool _showAtTopmost = false;
public bool ShowAtTopmost
{
get => _showAtTopmost;
set
{
if (_showAtTopmost != value)
{
_showAtTopmost = value;
OnPropertyChanged();
}
}
}
public bool SearchQueryResultsWithDelay { get; set; }
public int SearchDelayTime { get; set; } = 150;
[JsonConverter(typeof(JsonStringEnumConverter))]
public SearchWindowScreens SearchWindowScreen { get; set; } = SearchWindowScreens.Cursor;
@ -293,7 +505,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings
[JsonIgnore]
public bool WMPInstalled { get; set; } = true;
// This needs to be loaded last by staying at the bottom
public PluginsSettings PluginSettings { get; set; } = new PluginsSettings();
@ -304,30 +515,32 @@ namespace Flow.Launcher.Infrastructure.UserSettings
{
var list = FixedHotkeys();
// Customizeable hotkeys
if(!string.IsNullOrEmpty(Hotkey))
// Customizable hotkeys
if (!string.IsNullOrEmpty(Hotkey))
list.Add(new(Hotkey, "flowlauncherHotkey", () => Hotkey = ""));
if(!string.IsNullOrEmpty(PreviewHotkey))
if (!string.IsNullOrEmpty(PreviewHotkey))
list.Add(new(PreviewHotkey, "previewHotkey", () => PreviewHotkey = ""));
if(!string.IsNullOrEmpty(AutoCompleteHotkey))
if (!string.IsNullOrEmpty(AutoCompleteHotkey))
list.Add(new(AutoCompleteHotkey, "autoCompleteHotkey", () => AutoCompleteHotkey = ""));
if(!string.IsNullOrEmpty(AutoCompleteHotkey2))
if (!string.IsNullOrEmpty(AutoCompleteHotkey2))
list.Add(new(AutoCompleteHotkey2, "autoCompleteHotkey", () => AutoCompleteHotkey2 = ""));
if(!string.IsNullOrEmpty(SelectNextItemHotkey))
if (!string.IsNullOrEmpty(SelectNextItemHotkey))
list.Add(new(SelectNextItemHotkey, "SelectNextItemHotkey", () => SelectNextItemHotkey = ""));
if(!string.IsNullOrEmpty(SelectNextItemHotkey2))
if (!string.IsNullOrEmpty(SelectNextItemHotkey2))
list.Add(new(SelectNextItemHotkey2, "SelectNextItemHotkey", () => SelectNextItemHotkey2 = ""));
if(!string.IsNullOrEmpty(SelectPrevItemHotkey))
if (!string.IsNullOrEmpty(SelectPrevItemHotkey))
list.Add(new(SelectPrevItemHotkey, "SelectPrevItemHotkey", () => SelectPrevItemHotkey = ""));
if(!string.IsNullOrEmpty(SelectPrevItemHotkey2))
if (!string.IsNullOrEmpty(SelectPrevItemHotkey2))
list.Add(new(SelectPrevItemHotkey2, "SelectPrevItemHotkey", () => SelectPrevItemHotkey2 = ""));
if(!string.IsNullOrEmpty(SettingWindowHotkey))
if (!string.IsNullOrEmpty(SettingWindowHotkey))
list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = ""));
if(!string.IsNullOrEmpty(OpenContextMenuHotkey))
if (!string.IsNullOrEmpty(OpenHistoryHotkey))
list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = ""));
if (!string.IsNullOrEmpty(OpenContextMenuHotkey))
list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = ""));
if(!string.IsNullOrEmpty(SelectNextPageHotkey))
if (!string.IsNullOrEmpty(SelectNextPageHotkey))
list.Add(new(SelectNextPageHotkey, "SelectNextPageHotkey", () => SelectNextPageHotkey = ""));
if(!string.IsNullOrEmpty(SelectPrevPageHotkey))
if (!string.IsNullOrEmpty(SelectPrevPageHotkey))
list.Add(new(SelectPrevPageHotkey, "SelectPrevPageHotkey", () => SelectPrevPageHotkey = ""));
if (!string.IsNullOrEmpty(CycleHistoryUpHotkey))
list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = ""));
@ -358,7 +571,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings
new("Alt+Home", "HotkeySelectFirstResult"),
new("Alt+End", "HotkeySelectLastResult"),
new("Ctrl+R", "HotkeyRequery"),
new("Ctrl+H", "ToggleHistoryHotkey"),
new("Ctrl+OemCloseBrackets", "QuickWidthHotkey"),
new("Ctrl+OemOpenBrackets", "QuickWidthHotkey"),
new("Ctrl+OemPlus", "QuickHeightHotkey"),
@ -426,4 +638,25 @@ namespace Flow.Launcher.Infrastructure.UserSettings
Fast,
Custom
}
public enum BackdropTypes
{
None,
Acrylic,
Mica,
MicaAlt
}
public enum DoublePinyinSchemas
{
XiaoHe,
ZiRanMa,
WeiRuan,
ZhiNengABC,
ZiGuangPinYin,
PinYinJiaJia,
XingKongJianDao,
DaNiu,
XiaoLang
}
}

View file

@ -1,7 +1,27 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows.Interop;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Media;
using Flow.Launcher.Infrastructure.UserSettings;
using Microsoft.Win32;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.Shell.Common;
using Windows.Win32.UI.WindowsAndMessaging;
using Point = System.Windows.Point;
using SystemFonts = System.Windows.SystemFonts;
namespace Flow.Launcher.Infrastructure
{
@ -9,93 +29,803 @@ namespace Flow.Launcher.Infrastructure
{
#region Blur Handling
/*
Found on https://github.com/riverar/sample-win10-aeroglass
*/
private enum AccentState
public static bool IsBackdropSupported()
{
ACCENT_DISABLED = 0,
ACCENT_ENABLE_GRADIENT = 1,
ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
ACCENT_ENABLE_BLURBEHIND = 3,
ACCENT_INVALID_STATE = 4
// Mica and Acrylic only supported Windows 11 22000+
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
Environment.OSVersion.Version.Build >= 22000;
}
[StructLayout(LayoutKind.Sequential)]
private struct AccentPolicy
public static unsafe bool DWMSetCloakForWindow(Window window, bool cloak)
{
public AccentState AccentState;
public int AccentFlags;
public int GradientColor;
public int AnimationId;
var cloaked = cloak ? 1 : 0;
return PInvoke.DwmSetWindowAttribute(
GetWindowHandle(window),
DWMWINDOWATTRIBUTE.DWMWA_CLOAK,
&cloaked,
(uint)Marshal.SizeOf<int>()).Succeeded;
}
[StructLayout(LayoutKind.Sequential)]
private struct WindowCompositionAttributeData
public static unsafe bool DWMSetBackdropForWindow(Window window, BackdropTypes backdrop)
{
public WindowCompositionAttribute Attribute;
public IntPtr Data;
public int SizeOfData;
var backdropType = backdrop switch
{
BackdropTypes.Acrylic => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TRANSIENTWINDOW,
BackdropTypes.Mica => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_MAINWINDOW,
BackdropTypes.MicaAlt => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_TABBEDWINDOW,
_ => DWM_SYSTEMBACKDROP_TYPE.DWMSBT_AUTO
};
return PInvoke.DwmSetWindowAttribute(
GetWindowHandle(window),
DWMWINDOWATTRIBUTE.DWMWA_SYSTEMBACKDROP_TYPE,
&backdropType,
(uint)Marshal.SizeOf<int>()).Succeeded;
}
private enum WindowCompositionAttribute
public static unsafe bool DWMSetDarkModeForWindow(Window window, bool useDarkMode)
{
WCA_ACCENT_POLICY = 19
}
var darkMode = useDarkMode ? 1 : 0;
[DllImport("user32.dll")]
private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data);
return PInvoke.DwmSetWindowAttribute(
GetWindowHandle(window),
DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE,
&darkMode,
(uint)Marshal.SizeOf<int>()).Succeeded;
}
/// <summary>
/// Checks if the blur theme is enabled
///
/// </summary>
public static bool IsBlurTheme()
/// <param name="window"></param>
/// <param name="cornerType">DoNotRound, Round, RoundSmall, Default</param>
/// <returns></returns>
public static unsafe bool DWMSetCornerPreferenceForWindow(Window window, string cornerType)
{
if (Environment.OSVersion.Version >= new Version(6, 2))
var preference = cornerType switch
{
var resource = Application.Current.TryFindResource("ThemeBlurEnabled");
"DoNotRound" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DONOTROUND,
"Round" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND,
"RoundSmall" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUNDSMALL,
"Default" => DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT,
_ => throw new InvalidOperationException("Invalid corner type")
};
if (resource is bool b)
return b;
return PInvoke.DwmSetWindowAttribute(
GetWindowHandle(window),
DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE,
&preference,
(uint)Marshal.SizeOf<int>()).Succeeded;
}
#endregion
#region Wallpaper
public static unsafe string GetWallpaperPath()
{
var wallpaperPtr = stackalloc char[(int)PInvoke.MAX_PATH];
PInvoke.SystemParametersInfo(SYSTEM_PARAMETERS_INFO_ACTION.SPI_GETDESKWALLPAPER, PInvoke.MAX_PATH,
wallpaperPtr,
0);
var wallpaper = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(wallpaperPtr);
return wallpaper.ToString();
}
#endregion
#region Window Foreground
public static nint GetForegroundWindow()
{
return PInvoke.GetForegroundWindow().Value;
}
public static bool SetForegroundWindow(Window window)
{
return PInvoke.SetForegroundWindow(GetWindowHandle(window));
}
public static bool SetForegroundWindow(nint handle)
{
return PInvoke.SetForegroundWindow(new(handle));
}
public static bool IsForegroundWindow(Window window)
{
return IsForegroundWindow(GetWindowHandle(window));
}
internal static bool IsForegroundWindow(HWND handle)
{
return handle.Equals(PInvoke.GetForegroundWindow());
}
#endregion
#region Task Switching
/// <summary>
/// Hide windows in the Alt+Tab window list
/// </summary>
/// <param name="window">To hide a window</param>
public static void HideFromAltTab(Window window)
{
var hwnd = GetWindowHandle(window);
var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE);
// Add TOOLWINDOW style, remove APPWINDOW style
var newExStyle = ((uint)exStyle | (uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) & ~(uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW;
SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle);
}
/// <summary>
/// Restore window display in the Alt+Tab window list.
/// </summary>
/// <param name="window">To restore the displayed window</param>
public static void ShowInAltTab(Window window)
{
var hwnd = GetWindowHandle(window);
var exStyle = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE);
// Remove the TOOLWINDOW style and add the APPWINDOW style.
var newExStyle = ((uint)exStyle & ~(uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) | (uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW;
SetWindowStyle(GetWindowHandle(window), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle);
}
/// <summary>
/// Disable windows toolbar's control box
/// This will also disable system menu with Alt+Space hotkey
/// </summary>
public static void DisableControlBox(Window window)
{
var hwnd = GetWindowHandle(window);
var style = GetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE);
style &= ~(int)WINDOW_STYLE.WS_SYSMENU;
SetWindowStyle(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE, style);
}
private static nint GetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex)
{
var style = PInvoke.GetWindowLongPtr(hWnd, nIndex);
if (style == 0 && Marshal.GetLastPInvokeError() != 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError());
}
return style;
}
private static nint SetWindowStyle(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong)
{
PInvoke.SetLastError(WIN32_ERROR.NO_ERROR); // Clear any existing error
var result = PInvoke.SetWindowLongPtr(hWnd, nIndex, dwNewLong);
if (result == 0 && Marshal.GetLastPInvokeError() != 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError());
}
return result;
}
#endregion
#region Window Fullscreen
private const string WINDOW_CLASS_CONSOLE = "ConsoleWindowClass";
private const string WINDOW_CLASS_WINTAB = "Flip3D";
private const string WINDOW_CLASS_PROGMAN = "Progman";
private const string WINDOW_CLASS_WORKERW = "WorkerW";
private static HWND _hwnd_shell;
private static HWND HWND_SHELL =>
_hwnd_shell != HWND.Null ? _hwnd_shell : _hwnd_shell = PInvoke.GetShellWindow();
private static HWND _hwnd_desktop;
private static HWND HWND_DESKTOP =>
_hwnd_desktop != HWND.Null ? _hwnd_desktop : _hwnd_desktop = PInvoke.GetDesktopWindow();
public static unsafe bool IsForegroundWindowFullscreen()
{
// Get current active window
var hWnd = PInvoke.GetForegroundWindow();
if (hWnd.Equals(HWND.Null))
{
return false;
}
// If current active window is desktop or shell, exit early
if (hWnd.Equals(HWND_DESKTOP) || hWnd.Equals(HWND_SHELL))
{
return false;
}
string windowClass;
const int capacity = 256;
Span<char> buffer = stackalloc char[capacity];
int validLength;
fixed (char* pBuffer = buffer)
{
validLength = PInvoke.GetClassName(hWnd, pBuffer, capacity);
}
windowClass = buffer[..validLength].ToString();
// For Win+Tab (Flip3D)
if (windowClass == WINDOW_CLASS_WINTAB)
{
return false;
}
PInvoke.GetWindowRect(hWnd, out var appBounds);
// For console (ConsoleWindowClass), we have to check for negative dimensions
if (windowClass == WINDOW_CLASS_CONSOLE)
{
return appBounds.top < 0 && appBounds.bottom < 0;
}
// For desktop (Progman or WorkerW, depends on the system), we have to check
if (windowClass is WINDOW_CLASS_PROGMAN or WINDOW_CLASS_WORKERW)
{
var hWndDesktop = PInvoke.FindWindowEx(hWnd, HWND.Null, "SHELLDLL_DefView", null);
hWndDesktop = PInvoke.FindWindowEx(hWndDesktop, HWND.Null, "SysListView32", "FolderView");
if (hWndDesktop.Value != IntPtr.Zero)
{
return false;
}
}
var monitorInfo = MonitorInfo.GetNearestDisplayMonitor(hWnd);
return (appBounds.bottom - appBounds.top) == monitorInfo.RectMonitor.Height &&
(appBounds.right - appBounds.left) == monitorInfo.RectMonitor.Width;
}
#endregion
#region Pixel to DIP
/// <summary>
/// Transforms pixels to Device Independent Pixels used by WPF
/// </summary>
/// <param name="visual">current window, required to get presentation source</param>
/// <param name="unitX">horizontal position in pixels</param>
/// <param name="unitY">vertical position in pixels</param>
/// <returns>point containing device independent pixels</returns>
public static Point TransformPixelsToDIP(Visual visual, double unitX, double unitY)
{
Matrix matrix;
var source = PresentationSource.FromVisual(visual);
if (source is not null)
{
matrix = source.CompositionTarget.TransformFromDevice;
}
else
{
using var src = new HwndSource(new HwndSourceParameters());
matrix = src.CompositionTarget.TransformFromDevice;
}
return new Point((int)(matrix.M11 * unitX), (int)(matrix.M22 * unitY));
}
#endregion
#region WndProc
public const int WM_ENTERSIZEMOVE = (int)PInvoke.WM_ENTERSIZEMOVE;
public const int WM_EXITSIZEMOVE = (int)PInvoke.WM_EXITSIZEMOVE;
public const int WM_NCLBUTTONDBLCLK = (int)PInvoke.WM_NCLBUTTONDBLCLK;
public const int WM_SYSCOMMAND = (int)PInvoke.WM_SYSCOMMAND;
public const int SC_MAXIMIZE = (int)PInvoke.SC_MAXIMIZE;
public const int SC_MINIMIZE = (int)PInvoke.SC_MINIMIZE;
#endregion
#region Window Handle
internal static HWND GetWindowHandle(Window window, bool ensure = false)
{
var windowHelper = new WindowInteropHelper(window);
if (ensure)
{
windowHelper.EnsureHandle();
}
return new(windowHelper.Handle);
}
#endregion
#region STA Thread
/*
Inspired by https://github.com/files-community/Files code on STA Thread handling.
*/
public static Task StartSTATaskAsync(Action action)
{
var taskCompletionSource = new TaskCompletionSource();
Thread thread = new(() =>
{
PInvoke.OleInitialize();
try
{
action();
taskCompletionSource.SetResult();
}
catch (System.Exception ex)
{
taskCompletionSource.SetException(ex);
}
finally
{
PInvoke.OleUninitialize();
}
})
{
IsBackground = true,
Priority = ThreadPriority.Normal
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return taskCompletionSource.Task;
}
public static Task<T> StartSTATaskAsync<T>(Func<T> func)
{
var taskCompletionSource = new TaskCompletionSource<T>();
Thread thread = new(() =>
{
PInvoke.OleInitialize();
try
{
taskCompletionSource.SetResult(func());
}
catch (System.Exception ex)
{
taskCompletionSource.SetException(ex);
}
finally
{
PInvoke.OleUninitialize();
}
})
{
IsBackground = true,
Priority = ThreadPriority.Normal
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return taskCompletionSource.Task;
}
#endregion
#region Keyboard Layout
private const string UserProfileRegistryPath = @"Control Panel\International\User Profile";
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/70feba9f-294e-491e-b6eb-56532684c37f
private const string EnglishLanguageTag = "en";
private static readonly string[] ImeLanguageTags =
{
"zh", // Chinese
"ja", // Japanese
"ko", // Korean
};
private const uint KeyboardLayoutLoWord = 0xFFFF;
// Store the previous keyboard layout
private static HKL _previousLayout;
/// <summary>
/// Switches the keyboard layout to English if available.
/// </summary>
/// <param name="backupPrevious">If true, the current keyboard layout will be stored for later restoration.</param>
/// <exception cref="Win32Exception">Thrown when there's an error getting the window thread process ID.</exception>
public static unsafe void SwitchToEnglishKeyboardLayout(bool backupPrevious)
{
// Find an installed English layout
var enHKL = FindEnglishKeyboardLayout();
// No installed English layout found
if (enHKL == HKL.Null) return;
// Get the foreground window
var hwnd = PInvoke.GetForegroundWindow();
if (hwnd == HWND.Null) return;
// Get the current foreground window thread ID
var threadId = PInvoke.GetWindowThreadProcessId(hwnd);
if (threadId == 0) throw new Win32Exception(Marshal.GetLastWin32Error());
// If the current layout has an IME mode, disable it without switching to another layout.
// This is needed because for languages with IME mode, Flow Launcher just temporarily disables
// the IME mode instead of switching to another layout.
var currentLayout = PInvoke.GetKeyboardLayout(threadId);
var currentLangId = (uint)currentLayout.Value & KeyboardLayoutLoWord;
foreach (var imeLangTag in ImeLanguageTags)
{
var langTag = GetLanguageTag(currentLangId);
if (langTag.StartsWith(imeLangTag, StringComparison.OrdinalIgnoreCase)) return;
}
// Backup current keyboard layout
if (backupPrevious) _previousLayout = currentLayout;
// Switch to English layout
PInvoke.ActivateKeyboardLayout(enHKL, 0);
}
/// <summary>
/// Restores the previously backed-up keyboard layout.
/// If it wasn't backed up or has already been restored, this method does nothing.
/// </summary>
public static void RestorePreviousKeyboardLayout()
{
if (_previousLayout == HKL.Null) return;
var hwnd = PInvoke.GetForegroundWindow();
if (hwnd == HWND.Null) return;
PInvoke.PostMessage(
hwnd,
PInvoke.WM_INPUTLANGCHANGEREQUEST,
PInvoke.INPUTLANGCHANGE_FORWARD,
_previousLayout.Value
);
_previousLayout = HKL.Null;
}
/// <summary>
/// Finds an installed English keyboard layout.
/// </summary>
/// <returns></returns>
/// <exception cref="Win32Exception"></exception>
private static unsafe HKL FindEnglishKeyboardLayout()
{
// Get the number of keyboard layouts
int count = PInvoke.GetKeyboardLayoutList(0, null);
if (count <= 0) return HKL.Null;
// Get all keyboard layouts
var handles = new HKL[count];
fixed (HKL* h = handles)
{
var result = PInvoke.GetKeyboardLayoutList(count, h);
if (result == 0) throw new Win32Exception(Marshal.GetLastWin32Error());
}
// Look for any English keyboard layout
foreach (var hkl in handles)
{
// The lower word contains the language identifier
var langId = (uint)hkl.Value & KeyboardLayoutLoWord;
var langTag = GetLanguageTag(langId);
// Check if it's an English layout
if (langTag.StartsWith(EnglishLanguageTag, StringComparison.OrdinalIgnoreCase))
{
return hkl;
}
}
return HKL.Null;
}
/// <summary>
/// Returns the
/// <see href="https://learn.microsoft.com/globalization/locale/standard-locale-names">
/// BCP 47 language tag
/// </see>
/// of the current input language.
/// </summary>
/// <remarks>
/// Edited from: https://github.com/dotnet/winforms
/// </remarks>
private static string GetLanguageTag(uint langId)
{
// We need to convert the language identifier to a language tag, because they are deprecated and may have a
// transient value.
// https://learn.microsoft.com/globalization/locale/other-locale-names#lcid
// https://learn.microsoft.com/windows/win32/winmsg/wm-inputlangchange#remarks
//
// It turns out that the LCIDToLocaleName API, which is used inside CultureInfo, may return incorrect
// language tags for transient language identifiers. For example, it returns "nqo-GN" and "jv-Java-ID"
// instead of the "nqo" and "jv-Java" (as seen in the Get-WinUserLanguageList PowerShell cmdlet).
//
// Try to extract proper language tag from registry as a workaround approved by a Windows team.
// https://github.com/dotnet/winforms/pull/8573#issuecomment-1542600949
//
// NOTE: this logic may break in future versions of Windows since it is not documented.
if (langId is PInvoke.LOCALE_TRANSIENT_KEYBOARD1
or PInvoke.LOCALE_TRANSIENT_KEYBOARD2
or PInvoke.LOCALE_TRANSIENT_KEYBOARD3
or PInvoke.LOCALE_TRANSIENT_KEYBOARD4)
{
using var key = Registry.CurrentUser.OpenSubKey(UserProfileRegistryPath);
if (key?.GetValue("Languages") is string[] languages)
{
foreach (string language in languages)
{
using var subKey = key.OpenSubKey(language);
if (subKey?.GetValue("TransientLangId") is int transientLangId
&& transientLangId == langId)
{
return language;
}
}
}
}
return CultureInfo.GetCultureInfo((int)langId).Name;
}
#endregion
#region Notification
public static bool IsNotificationSupported()
{
// Notifications only supported on Windows 10 19041+
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
Environment.OSVersion.Version.Build >= 19041;
}
#endregion
#region Korean IME
public static bool IsWindows11()
{
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
Environment.OSVersion.Version.Build >= 22000;
}
public static bool IsKoreanIMEExist()
{
return GetLegacyKoreanIMERegistryValue() != null;
}
public static bool IsLegacyKoreanIMEEnabled()
{
object value = GetLegacyKoreanIMERegistryValue();
if (value is int intValue)
{
return intValue == 1;
}
else if (value != null && int.TryParse(value.ToString(), out int parsedValue))
{
return parsedValue == 1;
}
return false;
}
/// <summary>
/// Sets the blur for a window via SetWindowCompositionAttribute
/// </summary>
public static void SetBlurForWindow(Window w, bool blur)
public static bool SetLegacyKoreanIMEEnabled(bool enable)
{
SetWindowAccent(w, blur ? AccentState.ACCENT_ENABLE_BLURBEHIND : AccentState.ACCENT_DISABLED);
}
const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}";
const string valueName = "NoTsf3Override5";
private static void SetWindowAccent(Window w, AccentState state)
{
var windowHelper = new WindowInteropHelper(w);
windowHelper.EnsureHandle();
var accent = new AccentPolicy { AccentState = state };
var accentStructSize = Marshal.SizeOf(accent);
var accentPtr = Marshal.AllocHGlobal(accentStructSize);
Marshal.StructureToPtr(accent, accentPtr, false);
var data = new WindowCompositionAttributeData
try
{
Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY,
SizeOfData = accentStructSize,
Data = accentPtr
};
using RegistryKey key = Registry.CurrentUser.CreateSubKey(subKeyPath);
if (key != null)
{
int value = enable ? 1 : 0;
key.SetValue(valueName, value, RegistryValueKind.DWord);
return true;
}
}
catch (System.Exception)
{
// Ignored
}
SetWindowCompositionAttribute(windowHelper.Handle, ref data);
Marshal.FreeHGlobal(accentPtr);
return false;
}
public static object GetLegacyKoreanIMERegistryValue()
{
const string subKeyPath = @"Software\Microsoft\input\tsf\tsf3override\{A028AE76-01B1-46C2-99C4-ACD9858AE02F}";
const string valueName = "NoTsf3Override5";
try
{
using RegistryKey key = Registry.CurrentUser.OpenSubKey(subKeyPath);
if (key != null)
{
return key.GetValue(valueName);
}
}
catch (System.Exception)
{
// Ignored
}
return null;
}
public static void OpenImeSettings()
{
try
{
Process.Start(new ProcessStartInfo("ms-settings:regionlanguage") { UseShellExecute = true });
}
catch (System.Exception)
{
// Ignored
}
}
#endregion
#region System Font
private static readonly Dictionary<string, string> _languageToNotoSans = new()
{
{ "ko", "Noto Sans KR" },
{ "ja", "Noto Sans JP" },
{ "zh-CN", "Noto Sans SC" },
{ "zh-SG", "Noto Sans SC" },
{ "zh-Hans", "Noto Sans SC" },
{ "zh-TW", "Noto Sans TC" },
{ "zh-HK", "Noto Sans TC" },
{ "zh-MO", "Noto Sans TC" },
{ "zh-Hant", "Noto Sans TC" },
{ "th", "Noto Sans Thai" },
{ "ar", "Noto Sans Arabic" },
{ "he", "Noto Sans Hebrew" },
{ "hi", "Noto Sans Devanagari" },
{ "bn", "Noto Sans Bengali" },
{ "ta", "Noto Sans Tamil" },
{ "el", "Noto Sans Greek" },
{ "ru", "Noto Sans" },
{ "en", "Noto Sans" },
{ "fr", "Noto Sans" },
{ "de", "Noto Sans" },
{ "es", "Noto Sans" },
{ "pt", "Noto Sans" }
};
/// <summary>
/// Gets the system default font.
/// </summary>
/// <param name="useNoto">
/// If true, it will try to find the Noto font for the current culture.
/// </param>
/// <returns>
/// The name of the system default font.
/// </returns>
public static string GetSystemDefaultFont(bool useNoto = true)
{
try
{
if (useNoto)
{
var culture = CultureInfo.CurrentCulture;
var language = culture.Name; // e.g., "zh-TW"
var langPrefix = language.Split('-')[0]; // e.g., "zh"
// First, try to find by full name, and if not found, fallback to prefix
if (TryGetNotoFont(language, out var notoFont) || TryGetNotoFont(langPrefix, out notoFont))
{
// If the font is installed, return it
if (Fonts.SystemFontFamilies.Any(f => f.Source.Equals(notoFont)))
{
return notoFont;
}
}
}
// If Noto font is not found, fallback to the system default font
var font = SystemFonts.MessageFontFamily;
if (font.FamilyNames.TryGetValue(XmlLanguage.GetLanguage("en-US"), out var englishName))
{
return englishName;
}
return font.Source ?? "Segoe UI";
}
catch
{
return "Segoe UI";
}
}
private static bool TryGetNotoFont(string langKey, out string notoFont)
{
return _languageToNotoSans.TryGetValue(langKey, out notoFont);
}
#endregion
#region Explorer
// https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems
public static unsafe void OpenFolderAndSelectFile(string filePath)
{
ITEMIDLIST* pidlFolder = null;
ITEMIDLIST* pidlFile = null;
var folderPath = Path.GetDirectoryName(filePath);
try
{
var hrFolder = PInvoke.SHParseDisplayName(folderPath, null, out pidlFolder, 0, null);
if (hrFolder.Failed) throw new COMException("Failed to parse folder path", hrFolder);
var hrFile = PInvoke.SHParseDisplayName(filePath, null, out pidlFile, 0, null);
if (hrFile.Failed) throw new COMException("Failed to parse file path", hrFile);
var hrSelect = PInvoke.SHOpenFolderAndSelectItems(pidlFolder, 1, &pidlFile, 0);
if (hrSelect.Failed) throw new COMException("Failed to open folder and select item", hrSelect);
}
finally
{
if (pidlFile != null) PInvoke.CoTaskMemFree(pidlFile);
if (pidlFolder != null) PInvoke.CoTaskMemFree(pidlFolder);
}
}
#endregion
#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
}
}

View file

@ -0,0 +1,155 @@
{
"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.3, )",
"resolved": "2.5.3",
"contentHash": "Vo/39qcam5Xe+DbyfH0JZyqPswdOoa7jv4PGtRJ6Wj8AU+aZ+TuJRlJcIe+MQjRTJwliI8k8VSQpN8sEoBIv2g=="
},
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.0, )",
"resolved": "8.4.0",
"contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
},
"Fody": {
"type": "Direct",
"requested": "[6.5.5, )",
"resolved": "6.5.5",
"contentHash": "Krca41L/PDva1VsmDec5n52cQZxQAQp/bsHdzsNi8iLLI0lqKL94fNIkNaC8tVolUkCyWsbzvxfxJCeD2789fA=="
},
"MemoryPack": {
"type": "Direct",
"requested": "[1.21.3, )",
"resolved": "1.21.3",
"contentHash": "cwCtED8y400vMWx/Vp0QCSeEpVFjDU4JwF52VX9WTaqVERUvNqjG9n6osFlmFuytegyXnHvYEu1qRJ8rv/rkbg==",
"dependencies": {
"MemoryPack.Core": "1.21.3",
"MemoryPack.Generator": "1.21.3"
}
},
"Microsoft.VisualStudio.Threading": {
"type": "Direct",
"requested": "[17.12.19, )",
"resolved": "17.12.19",
"contentHash": "eLiGMkMYyaSguqHs3lsrFxy3tAWSLuPEL2pIWRcADMDVAs2xqm3dr1d9QYjiEusTgiClF9KD6OB2NdZP72Oy0Q==",
"dependencies": {
"Microsoft.VisualStudio.Threading.Analyzers": "17.12.19",
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Microsoft.Windows.CsWin32": {
"type": "Direct",
"requested": "[0.3.106, )",
"resolved": "0.3.106",
"contentHash": "Mx5fK7uN6fwLR4wUghs6//HonAnwPBNmC2oonyJVhCUlHS/r6SUS3NkBc3+gaQiv+0/9bqdj1oSCKQFkNI+21Q==",
"dependencies": {
"Microsoft.Windows.SDK.Win32Docs": "0.1.42-alpha",
"Microsoft.Windows.SDK.Win32Metadata": "60.0.34-preview",
"Microsoft.Windows.WDK.Win32Metadata": "0.11.4-experimental"
}
},
"NLog": {
"type": "Direct",
"requested": "[4.7.10, )",
"resolved": "4.7.10",
"contentHash": "rcegW7kYOCjl7wX0SzsqpPBqnJ51JKi1WkYb6QBVX0Wc5IgH19Pv4t/co+T0s06OS0Ne44xgkY/mHg0PdrmJow=="
},
"PropertyChanged.Fody": {
"type": "Direct",
"requested": "[3.4.0, )",
"resolved": "3.4.0",
"contentHash": "IAZyq0uolKo2WYm4mjx+q7A8fSGFT0x2e1s3y+ODn4JI0kqTDoo9GF2tdaypUzRFJZfdMxfC5HZW9QzdJLtOnA==",
"dependencies": {
"Fody": "6.5.1"
}
},
"System.Drawing.Common": {
"type": "Direct",
"requested": "[9.0.2, )",
"resolved": "9.0.2",
"contentHash": "JU947wzf8JbBS16Y5EIZzAlyQU+k68D7LRx6y03s2wlhlvLqkt/8uPBrjv2hJnnaJKbdb0GhQ3JZsfYXhrRjyg==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "9.0.2"
}
},
"ToolGood.Words.Pinyin": {
"type": "Direct",
"requested": "[3.0.1.4, )",
"resolved": "3.0.1.4",
"contentHash": "uQo97618y9yzLDxrnehPN+/tuiOlk5BqieEdwctHZOAS9miMXnHKgMFYVw8CSGXRglyTYXlrW7qtUlU7Fje5Ew=="
},
"JetBrains.Annotations": {
"type": "Transitive",
"resolved": "2024.3.0",
"contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug=="
},
"MemoryPack.Core": {
"type": "Transitive",
"resolved": "1.21.3",
"contentHash": "ajrYoBWT2aKeH4tlY8q/1C9qK1R/NK+7FkuVOX58ebOSxkABoFTqCR7W+Zk2rakUHZiEgNdRqO67hiRZPq6fLA=="
},
"MemoryPack.Generator": {
"type": "Transitive",
"resolved": "1.21.3",
"contentHash": "hYU0TAIarDKnbkNIWvb7P4zBUL+CTahkuNkczsKvycSMR5kiwQ4IfLexywNKX3s05Izp4gzDSPbueepNWZRpWA=="
},
"Microsoft.VisualStudio.Threading.Analyzers": {
"type": "Transitive",
"resolved": "17.12.19",
"contentHash": "v3IYeedjoktvZ+GqYmLudxZJngmf/YWIxNT2Uy6QMMN19cvw+nkWoip1Gr1RtnFkUo1MPUVMis4C8Kj8d8DpSQ=="
},
"Microsoft.VisualStudio.Validation": {
"type": "Transitive",
"resolved": "17.8.8",
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "9.0.2",
"contentHash": "5BkGZ6mHp2dHydR29sb0fDfAuqkv30AHtTih8wMzvPZysOmBFvHfnkR2w3tsc0pSiIg8ZoKyefJXWy9r3pBh0w=="
},
"Microsoft.Windows.SDK.Win32Docs": {
"type": "Transitive",
"resolved": "0.1.42-alpha",
"contentHash": "Z/9po23gUA9aoukirh2ItMU2ZS9++Js9Gdds9fu5yuMojDrmArvY2y+tq9985tR3cxFxpZO1O35Wjfo0khj5HA=="
},
"Microsoft.Windows.SDK.Win32Metadata": {
"type": "Transitive",
"resolved": "60.0.34-preview",
"contentHash": "TA3DUNi4CTeo+ItTXBnGZFt2159XOGSl0UOlG5vjDj4WHqZjhwYyyUnzOtrbCERiSaP2Hzg7otJNWwOSZgutyA=="
},
"Microsoft.Windows.WDK.Win32Metadata": {
"type": "Transitive",
"resolved": "0.11.4-experimental",
"contentHash": "bf5MCmUyZf0gBlYQjx9UpRAZWBkRndyt9XicR+UNLvAUAFTZQbu6YaX/sNKZlR98Grn0gydfh/yT4I3vc0AIQA==",
"dependencies": {
"Microsoft.Windows.SDK.Win32Metadata": "60.0.34-preview"
}
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "5NecZgXktdGg34rh1OenY1rFNDCI8xSjFr+Z4OU4cU06AQHUdRnIIEeWENu3Wl4YowbzkymAIMvi3WyK9U53pQ=="
},
"flow.launcher.plugin": {
"type": "Project",
"dependencies": {
"JetBrains.Annotations": "[2024.3.0, )",
"PropertyChanged.Fody": "[3.4.0, )"
}
}
}
}
}

View file

@ -51,6 +51,9 @@ namespace Flow.Launcher.Plugin
(WinPressed ? ModifierKeys.Windows : ModifierKeys.None);
}
/// <summary>
/// Default <see cref="SpecialKeyState"/> object with all keys not pressed.
/// </summary>
public static readonly SpecialKeyState Default = new () {
CtrlPressed = false,
ShiftPressed = false,

View file

@ -65,7 +65,42 @@ namespace Flow.Launcher.Plugin
public static bool IsDotNet(string language)
{
return language.Equals(CSharp, StringComparison.OrdinalIgnoreCase)
|| language.Equals(FSharp, StringComparison.OrdinalIgnoreCase);
|| language.Equals(FSharp, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Determines if this language is a Python language
/// </summary>
/// <param name="language"></param>
/// <returns></returns>
public static bool IsPython(string language)
{
return language.Equals(Python, StringComparison.OrdinalIgnoreCase)
|| language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Determines if this language is a Node.js language
/// </summary>
/// <param name="language"></param>
/// <returns></returns>
public static bool IsNodeJs(string language)
{
return language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase)
|| language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase)
|| language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase)
|| language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Determines if this language is a executable language
/// </summary>
/// <param name="language"></param>
/// <returns></returns>
public static bool IsExecutable(string language)
{
return language.Equals(Executable, StringComparison.OrdinalIgnoreCase)
|| language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@ -76,15 +111,9 @@ namespace Flow.Launcher.Plugin
public static bool IsAllowed(string language)
{
return IsDotNet(language)
|| language.Equals(Python, StringComparison.OrdinalIgnoreCase)
|| language.Equals(PythonV2, StringComparison.OrdinalIgnoreCase)
|| language.Equals(Executable, StringComparison.OrdinalIgnoreCase)
|| language.Equals(TypeScript, StringComparison.OrdinalIgnoreCase)
|| language.Equals(JavaScript, StringComparison.OrdinalIgnoreCase)
|| language.Equals(ExecutableV2, StringComparison.OrdinalIgnoreCase)
|| language.Equals(TypeScriptV2, StringComparison.OrdinalIgnoreCase)
|| language.Equals(JavaScriptV2, StringComparison.OrdinalIgnoreCase);
;
|| IsPython(language)
|| IsNodeJs(language)
|| IsExecutable(language);
}
}
}

View file

@ -39,7 +39,14 @@ namespace Flow.Launcher.Plugin
/// <param name="sender"></param>
/// <param name="args"></param>
public delegate void VisibilityChangedEventHandler(object sender, VisibilityChangedEventArgs args);
/// <summary>
/// A delegate for when the actual application theme is changed
/// </summary>
/// <param name="sender"></param>
/// <param name="args"></param>
public delegate void ActualApplicationThemeChangedEventHandler(object sender, ActualApplicationThemeChangedEventArgs args);
/// <summary>
/// The event args for <see cref="VisibilityChangedEventHandler"/>
/// </summary>
@ -77,4 +84,15 @@ namespace Flow.Launcher.Plugin
/// </summary>
public Query Query { get; set; }
}
/// <summary>
/// The event args for <see cref="ActualApplicationThemeChangedEventHandler"/>
/// </summary>
public class ActualApplicationThemeChangedEventArgs : EventArgs
{
/// <summary>
/// <see langword="true"/> if the application has changed actual theme
/// </summary>
public bool IsDark { get; init; }
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net9.0-windows</TargetFramework>
<ProjectGuid>{8451ECDD-2EA4-4966-BB0A-7BBC40138E80}</ProjectGuid>
<UseWPF>true</UseWPF>
<OutputType>Library</OutputType>
@ -11,13 +11,14 @@
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup>
<Version>4.4.0</Version>
<PackageVersion>4.4.0</PackageVersion>
<AssemblyVersion>4.4.0</AssemblyVersion>
<FileVersion>4.4.0</FileVersion>
<Version>4.7.0</Version>
<PackageVersion>4.7.0</PackageVersion>
<AssemblyVersion>4.7.0</AssemblyVersion>
<FileVersion>4.7.0</FileVersion>
<PackageId>Flow.Launcher.Plugin</PackageId>
<Authors>Flow-Launcher</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
@ -27,6 +28,7 @@
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageReadmeFile>Readme.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(APPVEYOR)' == 'True'">
@ -66,7 +68,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fody" Version="6.5.4">
<PackageReference Include="Fody" Version="6.5.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -76,7 +78,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Asynchronous Query Model for Flow Launcher When Query Text is Empty
/// </summary>
public interface IAsyncHomeQuery : IFeatures
{
/// <summary>
/// Asynchronous Querying When Query Text is Empty
/// </summary>
/// <para>
/// If the Querying method requires high IO transmission
/// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncHomeQuery interface
/// </para>
/// <param name="token">Cancel when querying job is obsolete</param>
/// <returns></returns>
Task<List<Result>> HomeQueryAsync(CancellationToken token);
}
}

View file

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Synchronous Query Model for Flow Launcher When Query Text is Empty
/// <para>
/// If the Querying method requires high IO transmission
/// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncHomeQuery interface
/// </para>
/// </summary>
public interface IHomeQuery : IAsyncHomeQuery
{
/// <summary>
/// Querying When Query Text is Empty
/// <para>
/// This method will be called within a Task.Run,
/// so please avoid synchronously wait for long.
/// </para>
/// </summary>
/// <returns></returns>
List<Result> HomeQuery();
Task<List<Result>> IAsyncHomeQuery.HomeQueryAsync(CancellationToken token) => Task.Run(HomeQuery);
}
}

View file

@ -1,6 +1,4 @@
using Flow.Launcher.Plugin.SharedModels;
using JetBrains.Annotations;
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
@ -8,6 +6,9 @@ using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using Flow.Launcher.Plugin.SharedModels;
using JetBrains.Annotations;
namespace Flow.Launcher.Plugin
{
@ -22,8 +23,8 @@ namespace Flow.Launcher.Plugin
/// </summary>
/// <param name="query">query text</param>
/// <param name="requery">
/// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one.
/// Set this to <see langword="true"/> 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 <see langword="true"/> to force Flow Launcher re-querying
/// </param>
void ChangeQuery(string query, bool requery = false);
@ -48,7 +49,7 @@ namespace Flow.Launcher.Plugin
/// </summary>
/// <param name="text">Text to save on clipboard</param>
/// <param name="directCopy">When true it will directly copy the file/folder from the path specified in text</param>
/// <param name="showDefaultNotification">Whether to show the default notification from this method after copy is done.
/// <param name="showDefaultNotification">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.</param>>
public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true);
@ -64,7 +65,7 @@ namespace Flow.Launcher.Plugin
void SavePluginSettings();
/// <summary>
/// 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.
@ -83,11 +84,25 @@ namespace Flow.Launcher.Plugin
/// <param name="subTitle">Optional message subtitle</param>
void ShowMsgError(string title, string subTitle = "");
/// <summary>
/// Show the error message using Flow's standard error icon.
/// </summary>
/// <param name="title">Message title</param>
/// <param name="buttonText">Message button content</param>
/// <param name="buttonAction">Message button action</param>
/// <param name="subTitle">Optional message subtitle</param>
void ShowMsgErrorWithButton(string title, string buttonText, Action buttonAction, string subTitle = "");
/// <summary>
/// Show the MainWindow when hiding
/// </summary>
void ShowMainWindow();
/// <summary>
/// Focus the query text box in the main window
/// </summary>
void FocusQueryTextBox();
/// <summary>
/// Hide MainWindow
/// </summary>
@ -100,7 +115,7 @@ namespace Flow.Launcher.Plugin
bool IsMainWindowVisible();
/// <summary>
/// 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.
/// </summary>
event VisibilityChangedEventHandler VisibilityChanged;
@ -121,6 +136,27 @@ namespace Flow.Launcher.Plugin
/// <param name="useMainWindowAsOwner">when true will use main windows as the owner</param>
void ShowMsg(string title, string subTitle, string iconPath, bool useMainWindowAsOwner = true);
/// <summary>
/// Show message box with button
/// </summary>
/// <param name="title">Message title</param>
/// <param name="buttonText">Message button content</param>
/// <param name="buttonAction">Message button action</param>
/// <param name="subTitle">Message subtitle</param>
/// <param name="iconPath">Message icon path (relative path to your plugin folder)</param>
void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle = "", string iconPath = "");
/// <summary>
/// Show message box with button
/// </summary>
/// <param name="title">Message title</param>
/// <param name="buttonText">Message button content</param>
/// <param name="buttonAction">Message button action</param>
/// <param name="subTitle">Message subtitle</param>
/// <param name="iconPath">Message icon path (relative path to your plugin folder)</param>
/// <param name="useMainWindowAsOwner">when true will use main windows as the owner</param>
void ShowMsgWithButton(string title, string buttonText, Action buttonAction, string subTitle, string iconPath, bool useMainWindowAsOwner = true);
/// <summary>
/// Open setting dialog
/// </summary>
@ -135,24 +171,55 @@ namespace Flow.Launcher.Plugin
string GetTranslation(string key);
/// <summary>
/// Get all loaded plugins
/// Get all loaded plugins
/// </summary>
/// <returns></returns>
List<PluginPair> GetAllPlugins();
/// <summary>
/// Register a callback for Global Keyboard Event
/// Registers a callback function for global keyboard events.
/// </summary>
/// <param name="callback"></param>
/// <param name="callback">
/// The callback function to invoke when a global keyboard event occurs.
/// <para>
/// Parameters:
/// <list type="number">
/// <item><description>int: The type of <see cref="KeyEvent"/> (key down, key up, etc.)</description></item>
/// <item><description>int: The virtual key code of the pressed/released key</description></item>
/// <item><description><see cref="SpecialKeyState"/>: The state of modifier keys (Ctrl, Alt, Shift, etc.)</description></item>
/// </list>
/// </para>
/// <para>
/// Returns: <c>true</c> to allow normal system processing of the key event,
/// or <c>false</c> to intercept and prevent default handling.
/// </para>
/// </param>
/// <remarks>
/// This callback will be invoked for all keyboard events system-wide.
/// Use with caution as intercepting system keys may affect normal system operation.
/// </remarks>
public void RegisterGlobalKeyboardCallback(Func<int, int, SpecialKeyState, bool> callback);
/// <summary>
/// Remove a callback for Global Keyboard Event
/// </summary>
/// <param name="callback"></param>
/// <param name="callback">
/// The callback function to invoke when a global keyboard event occurs.
/// <para>
/// Parameters:
/// <list type="number">
/// <item><description>int: The type of <see cref="KeyEvent"/> (key down, key up, etc.)</description></item>
/// <item><description>int: The virtual key code of the pressed/released key</description></item>
/// <item><description><see cref="SpecialKeyState"/>: The state of modifier keys (Ctrl, Alt, Shift, etc.)</description></item>
/// </list>
/// </para>
/// <para>
/// Returns: <c>true</c> to allow normal system processing of the key event,
/// or <c>false</c> to intercept and prevent default handling.
/// </para>
/// </param>
public void RemoveGlobalKeyboardCallback(Func<int, int, SpecialKeyState, bool> callback);
/// <summary>
/// Fuzzy Search the string with the given query. This is the core search mechanism Flow uses
/// </summary>
@ -162,7 +229,7 @@ namespace Flow.Launcher.Plugin
MatchResult FuzzySearch(string query, string stringToCompare);
/// <summary>
/// Http download the spefic url and return as string
/// Http download the specific url and return as string
/// </summary>
/// <param name="url">URL to call Http Get</param>
/// <param name="token">Cancellation Token</param>
@ -170,7 +237,7 @@ namespace Flow.Launcher.Plugin
Task<string> HttpGetStringAsync(string url, CancellationToken token = default);
/// <summary>
/// Http download the spefic url and return as stream
/// Http download the specific url and return as stream
/// </summary>
/// <param name="url">URL to call Http Get</param>
/// <param name="token">Cancellation Token</param>
@ -191,14 +258,19 @@ namespace Flow.Launcher.Plugin
Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action<double> reportProgress = null, CancellationToken token = default);
/// <summary>
/// Add ActionKeyword for specific plugin
/// Add ActionKeyword and update action keyword metadata for specific plugin.
/// Before adding, please check if action keyword is already assigned by <see cref="ActionKeywordAssigned"/>
/// </summary>
/// <param name="pluginId">ID for plugin that needs to add action keyword</param>
/// <param name="newActionKeyword">The actionkeyword that is supposed to be added</param>
/// <remarks>
/// If new action keyword contains any whitespace, FL will still add it but it will not work for users.
/// So plugin should check the whitespace before calling this function.
/// </remarks>
void AddActionKeyword(string pluginId, string newActionKeyword);
/// <summary>
/// Remove ActionKeyword for specific plugin
/// Remove ActionKeyword and update action keyword metadata for specific plugin
/// </summary>
/// <param name="pluginId">ID for plugin that needs to remove action keyword</param>
/// <param name="oldActionKeyword">The actionkeyword that is supposed to be removed</param>
@ -228,8 +300,13 @@ namespace Flow.Launcher.Plugin
void LogWarn(string className, string message, [CallerMemberName] string methodName = "");
/// <summary>
/// Log an Exception. Will throw if in debug mode so developer will be aware,
/// otherwise logs the eror message. This is the primary logging method used for Flow
/// Log error message. Preferred error logging method for plugins.
/// </summary>
void LogError(string className, string message, [CallerMemberName] string methodName = "");
/// <summary>
/// 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
/// </summary>
void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = "");
@ -242,9 +319,10 @@ namespace Flow.Launcher.Plugin
T LoadSettingJsonStorage<T>() where T : new();
/// <summary>
/// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow.Launcher
/// Save JsonStorage for current plugin's setting. This is the method used to save settings to json in Flow.
/// This method will save the original instance loaded with LoadJsonStorage.
/// This API call is for manually Save. Flow will automatically save all setting type that has called LoadSettingJsonStorage or SaveSettingJsonStorage previously.
/// This API call is for manually Save.
/// Flow will automatically save all setting type that has called <see cref="LoadSettingJsonStorage"/> or <see cref="SaveSettingJsonStorage"/> previously.
/// </summary>
/// <typeparam name="T">Type for Serialization</typeparam>
/// <returns></returns>
@ -258,13 +336,28 @@ namespace Flow.Launcher.Plugin
public void OpenDirectory(string DirectoryPath, string FileNameOrFilePath = null);
/// <summary>
/// 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.
/// </summary>
public void OpenWebUrl(Uri url, bool? inPrivate = null);
/// <summary>
/// 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.
/// </summary>
public void OpenWebUrl(string url, bool? inPrivate = null);
/// <summary>
/// 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.
/// </summary>
public void OpenUrl(Uri url, bool? inPrivate = null);
/// <summary>
/// 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.
/// </summary>
@ -300,7 +393,7 @@ namespace Flow.Launcher.Plugin
/// <summary>
/// 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.
/// </summary>
/// <param name="reselect">Choose the first result after reload if true; keep the last selected result if false. Default is true.</param>
public void ReQuery(bool reselect = true);
@ -344,5 +437,181 @@ namespace Flow.Launcher.Plugin
/// Stop the loading bar in main window
/// </summary>
public void StopLoadingBar();
/// <summary>
/// Get all available themes
/// </summary>
/// <returns></returns>
public List<ThemeData> GetAvailableThemes();
/// <summary>
/// Get the current theme
/// </summary>
/// <returns></returns>
public ThemeData GetCurrentTheme();
/// <summary>
/// Set the current theme
/// </summary>
/// <param name="theme"></param>
/// <returns>
/// True if the theme is set successfully, false otherwise.
/// </returns>
public bool SetCurrentTheme(ThemeData theme);
/// <summary>
/// Save all Flow's plugins caches
/// </summary>
void SavePluginCaches();
/// <summary>
/// Load BinaryStorage for current plugin's cache. This is the method used to load cache from binary in Flow.
/// When the file is not exist, it will create a new instance for the specific type.
/// </summary>
/// <typeparam name="T">Type for deserialization</typeparam>
/// <param name="cacheName">Cache file name</param>
/// <param name="cacheDirectory">Cache directory from plugin metadata</param>
/// <param name="defaultData">Default data to return</param>
/// <returns></returns>
/// <remarks>
/// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable <see href="https://github.com/Cysharp/MemoryPack"/>
/// </remarks>
Task<T> LoadCacheBinaryStorageAsync<T>(string cacheName, string cacheDirectory, T defaultData) where T : new();
/// <summary>
/// Save BinaryStorage for current plugin's cache. This is the method used to save cache to binary in Flow.
/// This method will save the original instance loaded with LoadCacheBinaryStorageAsync.
/// This API call is for manually Save.
/// Flow will automatically save all cache type that has called <see cref="LoadCacheBinaryStorageAsync"/> or <see cref="SaveCacheBinaryStorageAsync"/> previously.
/// </summary>
/// <typeparam name="T">Type for Serialization</typeparam>
/// <param name="cacheName">Cache file name</param>
/// <param name="cacheDirectory">Cache directory from plugin metadata</param>
/// <returns></returns>
/// <remarks>
/// BinaryStorage utilizes MemoryPack, which means the object must be MemoryPackSerializable <see href="https://github.com/Cysharp/MemoryPack"/>
/// </remarks>
Task SaveCacheBinaryStorageAsync<T>(string cacheName, string cacheDirectory) where T : new();
/// <summary>
/// Load image from path.
/// Support local, remote and data:image url.
/// Support png, jpg, jpeg, gif, bmp, tiff, ico, svg image files.
/// If image path is missing, it will return a missing icon.
/// </summary>
/// <param name="path">The path of the image.</param>
/// <param name="loadFullImage">
/// Load full image or not.
/// </param>
/// <param name="cacheImage">
/// Cache the image or not. Cached image will be stored in FL cache.
/// If the image is just used one time, it's better to set this to false.
/// </param>
/// <returns></returns>
ValueTask<ImageSource> LoadImageAsync(string path, bool loadFullImage = false, bool cacheImage = true);
/// <summary>
/// Update the plugin manifest
/// </summary>
/// <param name="usePrimaryUrlOnly">
/// FL has multiple urls to download the plugin manifest. Set this to true to only use the primary url.
/// </param>
/// <param name="token"></param>
/// <returns>True if the manifest is updated successfully, false otherwise</returns>
public Task<bool> UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default);
/// <summary>
/// Get the plugin manifest.
/// </summary>
/// <remarks>
/// If Flow cannot get manifest data, this could be null
/// </remarks>
/// <returns></returns>
public IReadOnlyList<UserPlugin> GetPluginManifest();
/// <summary>
/// Check if the plugin has been modified.
/// If this plugin is updated, installed or uninstalled and users do not restart the app,
/// it will be marked as modified
/// </summary>
/// <param name="id">Plugin id</param>
/// <returns></returns>
public bool PluginModified(string id);
/// <summary>
/// Update a plugin to new version, from a zip file. By default will remove the zip file if update is via url,
/// unless it's a local path installation
/// </summary>
/// <param name="pluginMetadata">The metadata of the old plugin to update</param>
/// <param name="plugin">The new plugin to update</param>
/// <param name="zipFilePath">
/// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed.
/// </param>
/// <returns>
/// True if the plugin is updated successfully, false otherwise.
/// </returns>
public Task<bool> UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath);
/// <summary>
/// Install a plugin. By default will remove the zip file if installation is from url,
/// unless it's a local path installation
/// </summary>
/// <param name="plugin">The plugin to install</param>
/// <param name="zipFilePath">
/// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed.
/// </param>
/// <returns>
/// True if the plugin is installed successfully, false otherwise.
/// </returns>
public bool InstallPlugin(UserPlugin plugin, string zipFilePath);
/// <summary>
/// Uninstall a plugin
/// </summary>
/// <param name="pluginMetadata">The metadata of the plugin to uninstall</param>
/// <param name="removePluginSettings">
/// Plugin has their own settings. If this is set to true, the plugin settings will be removed.
/// </param>
/// <returns>
/// True if the plugin is updated successfully, false otherwise.
/// </returns>
public Task<bool> UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false);
/// <summary>
/// Log debug message of the time taken to execute a method
/// Message will only be logged in Debug mode
/// </summary>
/// <returns>The time taken to execute the method in milliseconds</returns>
public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "");
/// <summary>
/// Log debug message of the time taken to execute a method asynchronously
/// Message will only be logged in Debug mode
/// </summary>
/// <returns>The time taken to execute the method in milliseconds</returns>
public Task<long> StopwatchLogDebugAsync(string className, string message, Func<Task> action, [CallerMemberName] string methodName = "");
/// <summary>
/// Log info message of the time taken to execute a method
/// </summary>
/// <returns>The time taken to execute the method in milliseconds</returns>
public long StopwatchLogInfo(string className, string message, Action action, [CallerMemberName] string methodName = "");
/// <summary>
/// Log info message of the time taken to execute a method asynchronously
/// </summary>
/// <returns>The time taken to execute the method in milliseconds</returns>
public Task<long> StopwatchLogInfoAsync(string className, string message, Func<Task> action, [CallerMemberName] string methodName = "");
/// <summary>
/// Representing whether the application is using a dark theme
/// </summary>
/// <returns></returns>
bool IsApplicationDarkTheme();
/// <summary>
/// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off.
/// </summary>
event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged;
}
}

View file

@ -4,17 +4,42 @@ using System.Threading;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Interface for plugins that want to manually update their results
/// </summary>
public interface IResultUpdated : IFeatures
{
/// <summary>
/// Event that is triggered when the results are updated
/// </summary>
event ResultUpdatedEventHandler ResultsUpdated;
}
/// <summary>
/// Delegate for the ResultsUpdated event
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
public delegate void ResultUpdatedEventHandler(IResultUpdated sender, ResultUpdatedEventArgs e);
/// <summary>
/// Event arguments for the ResultsUpdated event
/// </summary>
public class ResultUpdatedEventArgs : EventArgs
{
/// <summary>
/// List of results that should be displayed
/// </summary>
public List<Result> Results;
/// <summary>
/// Query that triggered the update
/// </summary>
public Query Query;
/// <summary>
/// Token that can be used to cancel the update
/// </summary>
public CancellationToken Token { get; init; }
}
}
}

View file

@ -1,18 +1,21 @@
namespace Flow.Launcher.Plugin
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Inherit this interface if additional data e.g. cache needs to be saved.
/// Inherit this interface if you need to save additional data which is not a setting or cache,
/// please implement this interface.
/// </summary>
/// <remarks>
/// For storing plugin settings, prefer <see cref="IPublicAPI.LoadSettingJsonStorage{T}"/>
/// or <see cref="IPublicAPI.SaveSettingJsonStorage{T}"/>.
/// Once called, your settings will be automatically saved by Flow.
/// or <see cref="IPublicAPI.SaveSettingJsonStorage{T}"/>.
/// For storing plugin caches, prefer <see cref="IPublicAPI.LoadCacheBinaryStorageAsync{T}"/>
/// or <see cref="IPublicAPI.SaveCacheBinaryStorageAsync{T}(string, string)"/>.
/// Once called, those settings and caches will be automatically saved by Flow.
/// </remarks>
public interface ISavable : IFeatures
{
/// <summary>
/// Save additional plugin data, such as cache.
/// Save additional plugin data.
/// </summary>
void Save();
}
}
}

View file

@ -2,8 +2,15 @@
namespace Flow.Launcher.Plugin
{
/// <summary>
/// This interface is used to create settings panel for .Net plugins
/// </summary>
public interface ISettingProvider
{
/// <summary>
/// Create settings panel control for .Net plugins
/// </summary>
/// <returns></returns>
Control CreateSettingPanel();
}
}

View file

@ -1,7 +1,12 @@
using Windows.Win32;
namespace Flow.Launcher.Infrastructure.Hotkey
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Enumeration of key events for
/// <see cref="IPublicAPI.RegisterGlobalKeyboardCallback(System.Func{int, int, SpecialKeyState, bool})"/>
/// and <see cref="IPublicAPI.RemoveGlobalKeyboardCallback(System.Func{int, int, SpecialKeyState, bool})"/>
/// </summary>
public enum KeyEvent
{
/// <summary>

View file

@ -1,3 +1,8 @@
EnumThreadWindows
GetWindowText
GetWindowTextLength
GetWindowTextLength
WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP

View file

@ -5,10 +5,18 @@
/// </summary>
public class PluginInitContext
{
/// <summary>
/// Default constructor.
/// </summary>
public PluginInitContext()
{
}
/// <summary>
/// Constructor.
/// </summary>
/// <param name="currentPluginMetadata"></param>
/// <param name="api"></param>
public PluginInitContext(PluginMetadata currentPluginMetadata, IPublicAPI api)
{
CurrentPluginMetadata = currentPluginMetadata;

View file

@ -4,24 +4,82 @@ using System.Text.Json.Serialization;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Plugin metadata
/// </summary>
public class PluginMetadata : BaseModel
{
private string _pluginDirectory;
/// <summary>
/// Plugin ID.
/// </summary>
public string ID { get; set; }
public string Name { get; set; }
public string Author { get; set; }
public string Version { get; set; }
public string Language { get; set; }
public string Description { get; set; }
public string Website { get; set; }
public bool Disabled { get; set; }
public string ExecuteFilePath { get; private set;}
/// <summary>
/// Plugin name.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Plugin author.
/// </summary>
public string Author { get; set; }
/// <summary>
/// Plugin version.
/// </summary>
public string Version { get; set; }
/// <summary>
/// Plugin language.
/// See <see cref="AllowedLanguage"/>
/// </summary>
public string Language { get; set; }
/// <summary>
/// Plugin description.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Plugin website.
/// </summary>
public string Website { get; set; }
/// <summary>
/// Whether plugin is disabled.
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// Whether plugin is disabled in home query.
/// </summary>
public bool HomeDisabled { get; set; }
/// <summary>
/// Plugin execute file path.
/// </summary>
public string ExecuteFilePath { get; private set; }
/// <summary>
/// Plugin execute file name.
/// </summary>
public string ExecuteFileName { get; set; }
/// <summary>
/// Plugin assembly name.
/// Only available for .Net plugins.
/// </summary>
[JsonIgnore]
public string AssemblyName { get; internal set; }
private string _pluginDirectory;
/// <summary>
/// Plugin source directory.
/// </summary>
public string PluginDirectory
{
get { return _pluginDirectory; }
get => _pluginDirectory;
internal set
{
_pluginDirectory = value;
@ -30,28 +88,77 @@ namespace Flow.Launcher.Plugin
}
}
/// <summary>
/// The first action keyword of plugin.
/// </summary>
public string ActionKeyword { get; set; }
/// <summary>
/// All action keywords of plugin.
/// </summary>
public List<string> ActionKeywords { get; set; }
public string IcoPath { get; set;}
public override string ToString()
{
return Name;
}
/// <summary>
/// Hide plugin keyword setting panel.
/// </summary>
public bool HideActionKeywordPanel { get; set; }
/// <summary>
/// Plugin search delay time in ms. Null means use default search delay time.
/// </summary>
public int? SearchDelayTime { get; set; } = null;
/// <summary>
/// Plugin icon path.
/// </summary>
public string IcoPath { get; set;}
/// <summary>
/// Plugin priority.
/// </summary>
[JsonIgnore]
public int Priority { get; set; }
/// <summary>
/// Init time include both plugin load time and init time
/// Init time include both plugin load time and init time.
/// </summary>
[JsonIgnore]
public long InitTime { get; set; }
/// <summary>
/// Average query time.
/// </summary>
[JsonIgnore]
public long AvgQueryTime { get; set; }
/// <summary>
/// Query count.
/// </summary>
[JsonIgnore]
public int QueryCount { get; set; }
/// <summary>
/// The path to the plugin settings directory which is not validated.
/// It is used to store plugin settings files and data files.
/// When plugin is deleted, FL will ask users whether to keep its settings.
/// If users do not want to keep, this directory will be deleted.
/// </summary>
public string PluginSettingsDirectoryPath { get; internal set; }
/// <summary>
/// The path to the plugin cache directory which is not validated.
/// It is used to store cache files.
/// When plugin is deleted, this directory will be deleted as well.
/// </summary>
public string PluginCacheDirectoryPath { get; internal set; }
/// <summary>
/// Convert <see cref="PluginMetadata"/> to string.
/// </summary>
/// <returns></returns>
public override string ToString()
{
return Name;
}
}
}

View file

@ -1,21 +1,37 @@
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Plugin instance and plugin metadata
/// </summary>
public class PluginPair
{
/// <summary>
/// Plugin instance
/// </summary>
public IAsyncPlugin Plugin { get; internal set; }
/// <summary>
/// Plugin metadata
/// </summary>
public PluginMetadata Metadata { get; internal set; }
/// <summary>
/// Convert to string
/// </summary>
/// <returns></returns>
public override string ToString()
{
return Metadata.Name;
}
/// <summary>
/// Compare by plugin metadata ID
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public override bool Equals(object obj)
{
PluginPair r = obj as PluginPair;
if (r != null)
if (obj is PluginPair r)
{
return string.Equals(r.Metadata.ID, Metadata.ID);
}
@ -25,6 +41,10 @@
}
}
/// <summary>
/// Get hash code
/// </summary>
/// <returns></returns>
public override int GetHashCode()
{
var hashcode = Metadata.ID?.GetHashCode() ?? 0;

View file

@ -2,12 +2,14 @@
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Represents a query that is sent to a plugin.
/// </summary>
public class Query
{
public Query() { }
/// <summary>
/// Raw query, this includes action keyword if it has
/// Raw query, this includes action keyword if it has.
/// It has handled buildin custom query shortkeys and build-in shortcuts, and it trims the whitespace.
/// We didn't recommend use this property directly. You should always use Search property.
/// </summary>
public string RawQuery { get; internal init; }
@ -19,6 +21,11 @@ namespace Flow.Launcher.Plugin
/// </summary>
public bool IsReQuery { get; internal set; } = false;
/// <summary>
/// Determines whether the query is a home query.
/// </summary>
public bool IsHomeQuery { get; internal init; } = false;
/// <summary>
/// Search part of a query.
/// This will not include action keyword if exclusive plugin gets it, otherwise it should be same as RawQuery.
@ -39,10 +46,9 @@ namespace Flow.Launcher.Plugin
public const string TermSeparator = " ";
/// <summary>
/// User can set multiple action keywords seperated by ';'
/// User can set multiple action keywords seperated by whitespace
/// </summary>
public const string ActionKeywordSeparator = ";";
public const string ActionKeywordSeparator = TermSeparator;
/// <summary>
/// Wildcard action keyword. Plugins using this value will be queried on every search.
@ -55,18 +61,18 @@ namespace Flow.Launcher.Plugin
/// </summary>
public string ActionKeyword { get; init; }
[JsonIgnore]
/// <summary>
/// Splits <see cref="SearchTerms"/> by spaces and returns the first item.
/// </summary>
/// <remarks>
/// returns an empty string when <see cref="SearchTerms"/> does not have enough items.
/// </remarks>
[JsonIgnore]
public string FirstSearch => SplitSearch(0);
[JsonIgnore]
private string _secondToEndSearch;
/// <summary>
/// strings from second search (including) to last search
/// </summary>

View file

@ -1,5 +1,4 @@
using System;
using System.Runtime;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
@ -13,6 +12,10 @@ namespace Flow.Launcher.Plugin
/// </summary>
public class Result
{
/// <summary>
/// Maximum score. This can be useful when set one result to the top by default. This is the score for the results set to the topmost by users.
/// </summary>
public const int MaxScore = int.MaxValue;
private string _pluginDirectory;
@ -20,6 +23,8 @@ namespace Flow.Launcher.Plugin
private string _copyText = string.Empty;
private string _badgeIcoPath;
/// <summary>
/// The title of the result. This is always required.
/// </summary>
@ -62,7 +67,7 @@ namespace Flow.Launcher.Plugin
/// <remarks>GlyphInfo is prioritized if not null</remarks>
public string IcoPath
{
get { return _icoPath; }
get => _icoPath;
set
{
// As a standard this property will handle prepping and converting to absolute local path for icon image processing
@ -82,6 +87,33 @@ namespace Flow.Launcher.Plugin
}
}
/// <summary>
/// The image to be displayed for the badge of the result.
/// </summary>
/// <value>Can be a local file path or a URL.</value>
/// <remarks>If null or empty, will use plugin icon</remarks>
public string BadgeIcoPath
{
get => _badgeIcoPath;
set
{
// As a standard this property will handle prepping and converting to absolute local path for icon image processing
if (!string.IsNullOrEmpty(value)
&& !string.IsNullOrEmpty(PluginDirectory)
&& !Path.IsPathRooted(value)
&& !value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
&& !value.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
{
_badgeIcoPath = Path.Combine(PluginDirectory, value);
}
else
{
_badgeIcoPath = value;
}
}
}
/// <summary>
/// Determines if Icon has a border radius
/// </summary>
@ -96,14 +128,18 @@ namespace Flow.Launcher.Plugin
/// <summary>
/// Delegate to load an icon for this result.
/// </summary>
public IconDelegate Icon;
public IconDelegate Icon = null;
/// <summary>
/// Delegate to load an icon for the badge of this result.
/// </summary>
public IconDelegate BadgeIcon = null;
/// <summary>
/// Information for Glyph Icon (Prioritized than IcoPath/Icon if user enable Glyph Icons)
/// </summary>
public GlyphInfo Glyph { get; init; }
/// <summary>
/// An action to take in the form of a function call when the result has been selected.
/// </summary>
@ -145,59 +181,19 @@ namespace Flow.Launcher.Plugin
/// </summary>
public string PluginDirectory
{
get { return _pluginDirectory; }
get => _pluginDirectory;
set
{
_pluginDirectory = value;
// When the Result object is returned from the query call, PluginDirectory is not provided until
// UpdatePluginMetadata call is made at PluginManager.cs L196. Once the PluginDirectory becomes available
// we need to update (only if not Uri path) the IcoPath with the full absolute path so the image can be loaded.
// we need to update (only if not Uri path) the IcoPath and BadgeIcoPath with the full absolute path so the image can be loaded.
IcoPath = _icoPath;
BadgeIcoPath = _badgeIcoPath;
}
}
/// <inheritdoc />
public override string ToString()
{
return Title + SubTitle + Score;
}
/// <summary>
/// Clones the current result
/// </summary>
public Result Clone()
{
return new Result
{
Title = Title,
SubTitle = SubTitle,
ActionKeywordAssigned = ActionKeywordAssigned,
CopyText = CopyText,
AutoCompleteText = AutoCompleteText,
IcoPath = IcoPath,
RoundedIcon = RoundedIcon,
Icon = Icon,
Glyph = Glyph,
Action = Action,
AsyncAction = AsyncAction,
Score = Score,
TitleHighlightData = TitleHighlightData,
OriginQuery = OriginQuery,
PluginDirectory = PluginDirectory,
ContextData = ContextData,
PluginID = PluginID,
TitleToolTip = TitleToolTip,
SubTitleToolTip = SubTitleToolTip,
PreviewPanel = PreviewPanel,
ProgressBar = ProgressBar,
ProgressBarColor = ProgressBarColor,
Preview = Preview,
AddSelectedCount = AddSelectedCount,
RecordKey = RecordKey
};
}
/// <summary>
/// Additional data associated with this result
/// </summary>
@ -226,16 +222,6 @@ namespace Flow.Launcher.Plugin
/// </summary>
public Lazy<UserControl> PreviewPanel { get; set; }
/// <summary>
/// Run this result, asynchronously
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public ValueTask<bool> ExecuteAsync(ActionContext context)
{
return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false);
}
/// <summary>
/// Progress bar display. Providing an int value between 0-100 will trigger the progress bar to be displayed on the result
/// </summary>
@ -257,11 +243,6 @@ namespace Flow.Launcher.Plugin
/// </summary>
public bool AddSelectedCount { get; set; } = true;
/// <summary>
/// Maximum score. This can be useful when set one result to the top by default. This is the score for the results set to the topmost by users.
/// </summary>
public const int MaxScore = int.MaxValue;
/// <summary>
/// The key to identify the record. This is used when FL checks whether the result is the topmost record. Or FL calculates the hashcode of the result for user selected records.
/// This can be useful when your plugin will change the Title or SubTitle of the result dynamically.
@ -270,6 +251,66 @@ namespace Flow.Launcher.Plugin
/// </summary>
public string RecordKey { get; set; } = null;
/// <summary>
/// Determines if the badge icon should be shown.
/// If users want to show the result badges and here you set this to true, the results will show the badge icon.
/// </summary>
public bool ShowBadge { get; set; } = false;
/// <summary>
/// Run this result, asynchronously
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public ValueTask<bool> ExecuteAsync(ActionContext context)
{
return AsyncAction?.Invoke(context) ?? ValueTask.FromResult(Action?.Invoke(context) ?? false);
}
/// <inheritdoc />
public override string ToString()
{
return Title + SubTitle + Score;
}
/// <summary>
/// Clones the current result
/// </summary>
public Result Clone()
{
return new Result
{
Title = Title,
SubTitle = SubTitle,
ActionKeywordAssigned = ActionKeywordAssigned,
CopyText = CopyText,
AutoCompleteText = AutoCompleteText,
IcoPath = IcoPath,
BadgeIcoPath = BadgeIcoPath,
RoundedIcon = RoundedIcon,
Icon = Icon,
BadgeIcon = BadgeIcon,
Glyph = Glyph,
Action = Action,
AsyncAction = AsyncAction,
Score = Score,
TitleHighlightData = TitleHighlightData,
OriginQuery = OriginQuery,
PluginDirectory = PluginDirectory,
ContextData = ContextData,
PluginID = PluginID,
TitleToolTip = TitleToolTip,
SubTitleToolTip = SubTitleToolTip,
PreviewPanel = PreviewPanel,
ProgressBar = ProgressBar,
ProgressBarColor = ProgressBarColor,
Preview = Preview,
AddSelectedCount = AddSelectedCount,
RecordKey = RecordKey,
ShowBadge = ShowBadge,
};
}
/// <summary>
/// Info of the preview section of a <see cref="Result"/>
/// </summary>

View file

@ -264,12 +264,12 @@ namespace Flow.Launcher.Plugin.SharedCommands
var index = path.LastIndexOf('\\');
if (index > 0 && index < (path.Length - 1))
{
string previousDirectoryPath = path.Substring(0, index + 1);
return locationExists(previousDirectoryPath) ? previousDirectoryPath : "";
string previousDirectoryPath = path[..(index + 1)];
return locationExists(previousDirectoryPath) ? previousDirectoryPath : string.Empty;
}
else
{
return "";
return string.Empty;
}
}
@ -285,7 +285,7 @@ namespace Flow.Launcher.Plugin.SharedCommands
// not full path, get previous level directory string
var indexOfSeparator = path.LastIndexOf('\\');
return path.Substring(0, indexOfSeparator + 1);
return path[..(indexOfSeparator + 1)];
}
return path;
@ -318,5 +318,51 @@ namespace Flow.Launcher.Plugin.SharedCommands
{
return path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
}
/// <summary>
/// Validates a directory, creating it if it doesn't exist
/// </summary>
/// <param name="path"></param>
public static void ValidateDirectory(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
/// <summary>
/// Validates a data directory, synchronizing it by ensuring all files from a bundled source directory exist in it.
/// If files are missing or outdated, they are copied from the bundled directory to the data directory.
/// </summary>
/// <param name="bundledDataDirectory"></param>
/// <param name="dataDirectory"></param>
public static void ValidateDataDirectory(string bundledDataDirectory, string dataDirectory)
{
if (!Directory.Exists(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
foreach (var bundledDataPath in Directory.GetFiles(bundledDataDirectory))
{
var data = Path.GetFileName(bundledDataPath);
if (data == null) continue;
var dataPath = Path.Combine(dataDirectory, data);
if (!File.Exists(dataPath))
{
File.Copy(bundledDataPath, dataPath);
}
else
{
var time1 = new FileInfo(bundledDataPath).LastWriteTimeUtc;
var time2 = new FileInfo(dataPath).LastWriteTimeUtc;
if (time1 != time2)
{
File.Copy(bundledDataPath, dataPath, true);
}
}
}
}
}
}

View file

@ -1,16 +1,20 @@
using Microsoft.Win32;
using System;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.Win32;
namespace Flow.Launcher.Plugin.SharedCommands
{
/// <summary>
/// Contains methods to open a search in a new browser window or tab.
/// </summary>
public static class SearchWeb
{
private static string GetDefaultBrowserPath()
{
string name = string.Empty;
var name = string.Empty;
try
{
using var regDefault = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice", false);
@ -20,8 +24,7 @@ namespace Flow.Launcher.Plugin.SharedCommands
name = regKey.GetValue(null).ToString().ToLower().Replace("\"", "");
if (!name.EndsWith("exe"))
name = name.Substring(0, name.LastIndexOf(".exe") + 4);
name = name[..(name.LastIndexOf(".exe") + 4)];
}
catch
{
@ -62,12 +65,21 @@ namespace Flow.Launcher.Plugin.SharedCommands
{
Process.Start(psi)?.Dispose();
}
catch (System.ComponentModel.Win32Exception)
// This error may be thrown if browser path is incorrect
catch (Win32Exception)
{
Process.Start(new ProcessStartInfo
try
{
FileName = url, UseShellExecute = true
});
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch
{
throw; // Re-throw the exception if we cannot open the URL in the default browser
}
}
}
@ -97,13 +109,21 @@ namespace Flow.Launcher.Plugin.SharedCommands
Process.Start(psi)?.Dispose();
}
// This error may be thrown if browser path is incorrect
catch (System.ComponentModel.Win32Exception)
catch (Win32Exception)
{
Process.Start(new ProcessStartInfo
try
{
FileName = url, UseShellExecute = true
});
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch
{
throw; // Re-throw the exception if we cannot open the URL in the default browser
}
}
}
}
}
}

View file

@ -8,12 +8,26 @@ using Windows.Win32.Foundation;
namespace Flow.Launcher.Plugin.SharedCommands
{
/// <summary>
/// Contains methods for running shell commands
/// </summary>
public static class ShellCommand
{
/// <summary>
/// Delegate for EnumThreadWindows
/// </summary>
/// <param name="hwnd"></param>
/// <param name="lParam"></param>
/// <returns></returns>
public delegate bool EnumThreadDelegate(IntPtr hwnd, IntPtr lParam);
private static bool containsSecurityWindow;
/// <summary>
/// Runs a windows command using the provided ProcessStartInfo
/// </summary>
/// <param name="processStartInfo"></param>
/// <returns></returns>
public static Process RunAsDifferentUser(ProcessStartInfo processStartInfo)
{
processStartInfo.Verb = "RunAsUser";
@ -65,6 +79,15 @@ namespace Flow.Launcher.Plugin.SharedCommands
return buffer[..length].ToString();
}
/// <summary>
/// Runs a windows command using the provided ProcessStartInfo
/// </summary>
/// <param name="fileName"></param>
/// <param name="workingDirectory"></param>
/// <param name="arguments"></param>
/// <param name="verb"></param>
/// <param name="createNoWindow"></param>
/// <returns></returns>
public static ProcessStartInfo SetProcessStartInfo(this string fileName, string workingDirectory = "",
string arguments = "", string verb = "", bool createNoWindow = false)
{

View file

@ -2,14 +2,29 @@
namespace Flow.Launcher.Plugin.SharedModels
{
/// <summary>
/// Represents the result of a match operation.
/// </summary>
public class MatchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="MatchResult"/> class.
/// </summary>
/// <param name="success"></param>
/// <param name="searchPrecision"></param>
public MatchResult(bool success, SearchPrecisionScore searchPrecision)
{
Success = success;
SearchPrecision = searchPrecision;
}
/// <summary>
/// Initializes a new instance of the <see cref="MatchResult"/> class.
/// </summary>
/// <param name="success"></param>
/// <param name="searchPrecision"></param>
/// <param name="matchData"></param>
/// <param name="rawScore"></param>
public MatchResult(bool success, SearchPrecisionScore searchPrecision, List<int> matchData, int rawScore)
{
Success = success;
@ -18,6 +33,9 @@ namespace Flow.Launcher.Plugin.SharedModels
RawScore = rawScore;
}
/// <summary>
/// Whether the match operation was successful.
/// </summary>
public bool Success { get; set; }
/// <summary>
@ -30,6 +48,9 @@ namespace Flow.Launcher.Plugin.SharedModels
/// </summary>
private int _rawScore;
/// <summary>
/// The raw calculated search score without any search precision filtering applied.
/// </summary>
public int RawScore
{
get { return _rawScore; }
@ -45,8 +66,15 @@ namespace Flow.Launcher.Plugin.SharedModels
/// </summary>
public List<int> MatchData { get; set; }
/// <summary>
/// The search precision score used to filter the search results.
/// </summary>
public SearchPrecisionScore SearchPrecision { get; set; }
/// <summary>
/// Determines if the search precision score is met.
/// </summary>
/// <returns></returns>
public bool IsSearchPrecisionScoreMet()
{
return IsSearchPrecisionScoreMet(_rawScore);
@ -63,10 +91,24 @@ namespace Flow.Launcher.Plugin.SharedModels
}
}
/// <summary>
/// Represents the search precision score used to filter search results.
/// </summary>
public enum SearchPrecisionScore
{
/// <summary>
/// The highest search precision score.
/// </summary>
Regular = 50,
/// <summary>
/// The medium search precision score.
/// </summary>
Low = 20,
/// <summary>
/// The lowest search precision score.
/// </summary>
None = 0
}
}

View file

@ -0,0 +1,77 @@
using System;
namespace Flow.Launcher.Plugin.SharedModels;
/// <summary>
/// Theme data model
/// </summary>
public class ThemeData
{
/// <summary>
/// Theme file name without extension
/// </summary>
public string FileNameWithoutExtension { get; private init; }
/// <summary>
/// Theme name
/// </summary>
public string Name { get; private init; }
/// <summary>
/// Indicates whether the theme supports dark mode
/// </summary>
public bool? IsDark { get; private init; }
/// <summary>
/// Indicates whether the theme supports blur effects
/// </summary>
public bool? HasBlur { get; private init; }
/// <summary>
/// Theme data constructor
/// </summary>
public ThemeData(string fileNameWithoutExtension, string name, bool? isDark = null, bool? hasBlur = null)
{
FileNameWithoutExtension = fileNameWithoutExtension;
Name = name;
IsDark = isDark;
HasBlur = hasBlur;
}
/// <inheritdoc />
public static bool operator ==(ThemeData left, ThemeData right)
{
if (left is null && right is null)
return true;
if (left is null || right is null)
return false;
return left.Equals(right);
}
/// <inheritdoc />
public static bool operator !=(ThemeData left, ThemeData right)
{
return !(left == right);
}
/// <inheritdoc />
public override bool Equals(object obj)
{
if (obj is not ThemeData other)
return false;
return FileNameWithoutExtension == other.FileNameWithoutExtension &&
Name == other.Name;
}
/// <inheritdoc />
public override int GetHashCode()
{
return HashCode.Combine(FileNameWithoutExtension, Name);
}
/// <inheritdoc />
public override string ToString()
{
return Name;
}
}

View file

@ -0,0 +1,80 @@
using System;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// User Plugin Model for Flow Launcher
/// </summary>
public record UserPlugin
{
/// <summary>
/// Unique identifier of the plugin
/// </summary>
public string ID { get; set; }
/// <summary>
/// Name of the plugin
/// </summary>
public string Name { get; set; }
/// <summary>
/// Description of the plugin
/// </summary>
public string Description { get; set; }
/// <summary>
/// Author of the plugin
/// </summary>
public string Author { get; set; }
/// <summary>
/// Version of the plugin
/// </summary>
public string Version { get; set; }
/// <summary>
/// Allow language of the plugin <see cref="AllowedLanguage"/>
/// </summary>
public string Language { get; set; }
/// <summary>
/// Website of the plugin
/// </summary>
public string Website { get; set; }
/// <summary>
/// URL to download the plugin
/// </summary>
public string UrlDownload { get; set; }
/// <summary>
/// URL to the source code of the plugin
/// </summary>
public string UrlSourceCode { get; set; }
/// <summary>
/// Local path where the plugin is installed
/// </summary>
public string LocalInstallPath { get; set; }
/// <summary>
/// Icon path of the plugin
/// </summary>
public string IcoPath { get; set; }
/// <summary>
/// The date when the plugin was last updated
/// </summary>
public DateTime? LatestReleaseDate { get; set; }
/// <summary>
/// The date when the plugin was added to the local system
/// </summary>
public DateTime? DateAdded { get; set; }
/// <summary>
/// Indicates whether the plugin is installed from a local path
/// </summary>
public bool IsFromLocalInstallPath => !string.IsNullOrEmpty(LocalInstallPath);
}
}

View file

@ -0,0 +1,77 @@
{
"version": 1,
"dependencies": {
"net9.0-windows7.0": {
"Fody": {
"type": "Direct",
"requested": "[6.5.4, )",
"resolved": "6.5.4",
"contentHash": "GXZuti428IZctfby10xkMbWLCibcb6s29I/psLbBoO2vHJI5eTNVybnlV/Wi1tlIu9GG0bgW/PQwMH+MCldHxw=="
},
"JetBrains.Annotations": {
"type": "Direct",
"requested": "[2024.3.0, )",
"resolved": "2024.3.0",
"contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug=="
},
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[1.1.1, )",
"resolved": "1.1.1",
"contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
},
"Microsoft.Windows.CsWin32": {
"type": "Direct",
"requested": "[0.3.106, )",
"resolved": "0.3.106",
"contentHash": "Mx5fK7uN6fwLR4wUghs6//HonAnwPBNmC2oonyJVhCUlHS/r6SUS3NkBc3+gaQiv+0/9bqdj1oSCKQFkNI+21Q==",
"dependencies": {
"Microsoft.Windows.SDK.Win32Docs": "0.1.42-alpha",
"Microsoft.Windows.SDK.Win32Metadata": "60.0.34-preview",
"Microsoft.Windows.WDK.Win32Metadata": "0.11.4-experimental"
}
},
"PropertyChanged.Fody": {
"type": "Direct",
"requested": "[3.4.0, )",
"resolved": "3.4.0",
"contentHash": "IAZyq0uolKo2WYm4mjx+q7A8fSGFT0x2e1s3y+ODn4JI0kqTDoo9GF2tdaypUzRFJZfdMxfC5HZW9QzdJLtOnA==",
"dependencies": {
"Fody": "6.5.1"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q=="
},
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg=="
},
"Microsoft.Windows.SDK.Win32Docs": {
"type": "Transitive",
"resolved": "0.1.42-alpha",
"contentHash": "Z/9po23gUA9aoukirh2ItMU2ZS9++Js9Gdds9fu5yuMojDrmArvY2y+tq9985tR3cxFxpZO1O35Wjfo0khj5HA=="
},
"Microsoft.Windows.SDK.Win32Metadata": {
"type": "Transitive",
"resolved": "60.0.34-preview",
"contentHash": "TA3DUNi4CTeo+ItTXBnGZFt2159XOGSl0UOlG5vjDj4WHqZjhwYyyUnzOtrbCERiSaP2Hzg7otJNWwOSZgutyA=="
},
"Microsoft.Windows.WDK.Win32Metadata": {
"type": "Transitive",
"resolved": "0.11.4-experimental",
"contentHash": "bf5MCmUyZf0gBlYQjx9UpRAZWBkRndyt9XicR+UNLvAUAFTZQbu6YaX/sNKZlR98Grn0gydfh/yT4I3vc0AIQA==",
"dependencies": {
"Microsoft.Windows.SDK.Win32Metadata": "60.0.34-preview"
}
}
}
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class ChineseDetectionPerformanceTest
{
private readonly List<string> _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<string, List<string>>
{
["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);
}
/// <summary>
/// Optimized Chinese character detection using comprehensive CJK Unicode ranges
/// This method uses ReadOnlySpan for better performance and covers all CJK character ranges
/// </summary>
private static bool ContainsChinese(ReadOnlySpan<char> text)
{
foreach (var c in text)
{
if (IsChineseCharacter(c))
return true;
}
return false;
}
/// <summary>
/// Check if a character is a Chinese character using comprehensive Unicode ranges
/// Covers CJK Unified Ideographs and all extension blocks
/// </summary>
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
}
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows10.0.19041.0</TargetFramework>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<ProjectGuid>{FF742965-9A80-41A5-B042-D6C7D3A21708}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>

View file

@ -39,8 +39,8 @@ namespace Flow.Launcher.Test.Plugins
}
[SupportedOSPlatform("windows7.0")]
[TestCase("C:\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY System.FileName")]
[TestCase("C:\\SomeFolder\\", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY System.FileName")]
[TestCase("C:\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\' ORDER BY {QueryConstructor.OrderIdentifier}")]
[TestCase("C:\\SomeFolder\\", $"SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType FROM SystemIndex WHERE directory='file:C:\\SomeFolder\\' ORDER BY {QueryConstructor.OrderIdentifier}")]
public void GivenWindowsIndexSearch_WhenSearchTypeIsTopLevelDirectorySearch_ThenQueryShouldUseExpectedString(string folderPath, string expectedString)
{
// Given
@ -59,7 +59,7 @@ namespace Flow.Launcher.Test.Plugins
[TestCase("C:\\SomeFolder", "flow.launcher.sln", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType" +
" FROM SystemIndex WHERE directory='file:C:\\SomeFolder'" +
" AND (System.FileName LIKE 'flow.launcher.sln%' OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"'))" +
" ORDER BY System.FileName")]
$" ORDER BY {QueryConstructor.OrderIdentifier}")]
public void GivenWindowsIndexSearchTopLevelDirectory_WhenSearchingForSpecificItem_ThenQueryShouldUseExpectedString(
string folderPath, string userSearchString, string expectedString)
{
@ -87,8 +87,8 @@ namespace Flow.Launcher.Test.Plugins
[SupportedOSPlatform("windows7.0")]
[TestCase("flow.launcher.sln", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" " +
"FROM \"SystemIndex\" WHERE (System.FileName LIKE 'flow.launcher.sln%' " +
"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY System.FileName")]
[TestCase("", "SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY System.FileName")]
$"OR CONTAINS(System.FileName,'\"flow.launcher.sln*\"',1033)) AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")]
[TestCase("", $"SELECT TOP 100 \"System.FileName\", \"System.ItemUrl\", \"System.ItemType\" FROM \"SystemIndex\" WHERE WorkId IS NOT NULL AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")]
public void GivenWindowsIndexSearch_WhenSearchAllFoldersAndFiles_ThenQueryShouldUseExpectedString(
string userSearchString, string expectedString)
{
@ -107,7 +107,6 @@ namespace Flow.Launcher.Test.Plugins
ClassicAssert.AreEqual(expectedString, resultString);
}
[SupportedOSPlatform("windows7.0")]
[TestCase(@"some words", @"FREETEXT('some words')")]
public void GivenWindowsIndexSearch_WhenQueryWhereRestrictionsIsForFileContentSearch_ThenShouldReturnFreeTextString(
@ -127,7 +126,7 @@ namespace Flow.Launcher.Test.Plugins
[SupportedOSPlatform("windows7.0")]
[TestCase("some words", "SELECT TOP 100 System.FileName, System.ItemUrl, System.ItemType " +
"FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY System.FileName")]
$"FROM SystemIndex WHERE FREETEXT('some words') AND scope='file:' ORDER BY {QueryConstructor.OrderIdentifier}")]
public void GivenWindowsIndexSearch_WhenSearchForFileContent_ThenQueryShouldUseExpectedString(
string userSearchString, string expectedString)
{

Some files were not shown because too many files have changed in this diff Show more