diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dc464ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @phoseinq +/.github/ @phoseinq +/src/ @phoseinq diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26bfe71 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main, V3] + pull_request: + branches: [main, V3] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test-policy: + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 9.0.x + cache: true + cache-dependency-path: '**/*.csproj' + + - name: Restore + run: dotnet restore Halo.sln + + - name: Build + run: dotnet build Halo.sln -c Release --no-restore --nologo -warnaserror + + - name: Test + run: dotnet test tests\Halo.Tests\Halo.Tests.csproj -c Release --no-build --nologo + + - name: Self-test the policy script + shell: pwsh + run: pwsh -NoProfile -File scripts\verify-public-source.ps1 -SelfTest + + # A push to V3 is a mirror publish, so an unstripped file there is our own bug and must fail. A pull + # request is somebody else's work: the comment rule drops to a warning annotation so a patch is never + # rejected for explaining itself. Tabs and the package allowlist fail in both cases. + - name: Check public source policy (push) + if: github.event_name != 'pull_request' + shell: pwsh + run: pwsh -NoProfile -File scripts\verify-public-source.ps1 + + - name: Check public source policy (pull request) + if: github.event_name == 'pull_request' + shell: pwsh + run: pwsh -NoProfile -File scripts\verify-public-source.ps1 -CommentsAdvisory diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0cd4890 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,65 @@ +name: Security + +on: + push: + branches: [main, V3] + pull_request: + branches: [main, V3] + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + codeql: + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + with: + languages: csharp + build-mode: manual + + - name: Set up .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 9.0.x + cache: true + cache-dependency-path: '**/*.csproj' + + - name: Restore + run: dotnet restore Halo.sln + + - name: Build + run: dotnet build Halo.sln -c Release --no-restore --nologo -warnaserror + + - name: Analyze + uses: github/codeql-action/analyze@4187e74d05793876e9989daffde9c3e66b4acd07 # v3 + + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Review dependency changes + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4 + with: + fail-on-severity: moderate diff --git a/.gitignore b/.gitignore index 81c8a43..37e0d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ +bin/ +obj/ .vs/ -/DynamicWin/bin/ -/DynamicWin/obj/ -/DynamicWinSetup \ No newline at end of file +*.user +*.log +_reference/ +publish/ +.worktrees/ +.superpowers/ +dist/ +.claude/settings.local.json +.serena/logs/ diff --git a/Halo.sln b/Halo.sln new file mode 100644 index 0000000..95f526a --- /dev/null +++ b/Halo.sln @@ -0,0 +1,84 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Halo.App", "src\Halo.App\Halo.App.csproj", "{B2DA8E05-575B-410A-ACA1-AA881D1574CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Halo.Tests", "tests\Halo.Tests\Halo.Tests.csproj", "{5F246822-CC7C-4909-A80D-FDD271F7308D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Halo.Hooks", "src\Halo.Hooks\Halo.Hooks.csproj", "{9FA9150C-C7E3-4E31-958D-C86419F89182}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Halo.Settings", "src\Halo.Settings\Halo.Settings.csproj", "{88089641-797D-4D9C-A4A1-9D11A6B52A4A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|x64.Build.0 = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Debug|x86.Build.0 = Debug|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|Any CPU.Build.0 = Release|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|x64.ActiveCfg = Release|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|x64.Build.0 = Release|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|x86.ActiveCfg = Release|Any CPU + {B2DA8E05-575B-410A-ACA1-AA881D1574CF}.Release|x86.Build.0 = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|x64.Build.0 = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Debug|x86.Build.0 = Debug|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|Any CPU.Build.0 = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|x64.ActiveCfg = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|x64.Build.0 = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|x86.ActiveCfg = Release|Any CPU + {5F246822-CC7C-4909-A80D-FDD271F7308D}.Release|x86.Build.0 = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|x64.ActiveCfg = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|x64.Build.0 = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|x86.ActiveCfg = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Debug|x86.Build.0 = Debug|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|Any CPU.Build.0 = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|x64.ActiveCfg = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|x64.Build.0 = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|x86.ActiveCfg = Release|Any CPU + {9FA9150C-C7E3-4E31-958D-C86419F89182}.Release|x86.Build.0 = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|x64.ActiveCfg = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|x64.Build.0 = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|x86.ActiveCfg = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Debug|x86.Build.0 = Debug|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|Any CPU.Build.0 = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|x64.ActiveCfg = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|x64.Build.0 = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|x86.ActiveCfg = Release|Any CPU + {88089641-797D-4D9C-A4A1-9D11A6B52A4A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B2DA8E05-575B-410A-ACA1-AA881D1574CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5F246822-CC7C-4909-A80D-FDD271F7308D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9FA9150C-C7E3-4E31-958D-C86419F89182} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {88089641-797D-4D9C-A4A1-9D11A6B52A4A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE index 3b7b82d..b4dc173 100644 --- a/LICENSE +++ b/LICENSE @@ -1,427 +1,21 @@ -Attribution-ShareAlike 4.0 International - -======================================================================= - -Creative Commons Corporation ("Creative Commons") is not a law firm and -does not provide legal services or legal advice. Distribution of -Creative Commons public licenses does not create a lawyer-client or -other relationship. Creative Commons makes its licenses and related -information available on an "as-is" basis. Creative Commons gives no -warranties regarding its licenses, any material licensed under their -terms and conditions, or any related information. Creative Commons -disclaims all liability for damages resulting from their use to the -fullest extent possible. - -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and -conditions that creators and other rights holders may use to share -original works of authorship and other material subject to copyright -and certain other rights specified in the public license below. The -following considerations are for informational purposes only, are not -exhaustive, and do not form part of our licenses. - - Considerations for licensors: Our public licenses are - intended for use by those authorized to give the public - permission to use material in ways otherwise restricted by - copyright and certain other rights. Our licenses are - irrevocable. Licensors should read and understand the terms - and conditions of the license they choose before applying it. - Licensors should also secure all rights necessary before - applying our licenses so that the public can reuse the - material as expected. Licensors should clearly mark any - material not subject to the license. This includes other CC- - licensed material, or material used under an exception or - limitation to copyright. More considerations for licensors: - wiki.creativecommons.org/Considerations_for_licensors - - Considerations for the public: By using one of our public - licenses, a licensor grants the public permission to use the - licensed material under specified terms and conditions. If - the licensor's permission is not necessary for any reason--for - example, because of any applicable exception or limitation to - copyright--then that use is not regulated by the license. Our - licenses grant only permissions under copyright and certain - other rights that a licensor has authority to grant. Use of - the licensed material may still be restricted for other - reasons, including because others have copyright or other - rights in the material. A licensor may make special requests, - such as asking that all changes be marked or described. - Although not required by our licenses, you are encouraged to - respect those requests where reasonable. More_considerations - for the public: - wiki.creativecommons.org/Considerations_for_licensees - -======================================================================= - -Creative Commons Attribution-ShareAlike 4.0 International Public -License - -By exercising the Licensed Rights (defined below), You accept and agree -to be bound by the terms and conditions of this Creative Commons -Attribution-ShareAlike 4.0 International Public License ("Public -License"). To the extent this Public License may be interpreted as a -contract, You are granted the Licensed Rights in consideration of Your -acceptance of these terms and conditions, and the Licensor grants You -such rights in consideration of benefits the Licensor receives from -making the Licensed Material available under these terms and -conditions. - - -Section 1 -- Definitions. - - a. Adapted Material means material subject to Copyright and Similar - Rights that is derived from or based upon the Licensed Material - and in which the Licensed Material is translated, altered, - arranged, transformed, or otherwise modified in a manner requiring - permission under the Copyright and Similar Rights held by the - Licensor. For purposes of this Public License, where the Licensed - Material is a musical work, performance, or sound recording, - Adapted Material is always produced where the Licensed Material is - synched in timed relation with a moving image. - - b. Adapter's License means the license You apply to Your Copyright - and Similar Rights in Your contributions to Adapted Material in - accordance with the terms and conditions of this Public License. - - c. BY-SA Compatible License means a license listed at - creativecommons.org/compatiblelicenses, approved by Creative - Commons as essentially the equivalent of this Public License. - - d. Copyright and Similar Rights means copyright and/or similar rights - closely related to copyright including, without limitation, - performance, broadcast, sound recording, and Sui Generis Database - Rights, without regard to how the rights are labeled or - categorized. For purposes of this Public License, the rights - specified in Section 2(b)(1)-(2) are not Copyright and Similar - Rights. - - e. Effective Technological Measures means those measures that, in the - absence of proper authority, may not be circumvented under laws - fulfilling obligations under Article 11 of the WIPO Copyright - Treaty adopted on December 20, 1996, and/or similar international - agreements. - - f. Exceptions and Limitations means fair use, fair dealing, and/or - any other exception or limitation to Copyright and Similar Rights - that applies to Your use of the Licensed Material. - - g. License Elements means the license attributes listed in the name - of a Creative Commons Public License. The License Elements of this - Public License are Attribution and ShareAlike. - - h. Licensed Material means the artistic or literary work, database, - or other material to which the Licensor applied this Public - License. - - i. Licensed Rights means the rights granted to You subject to the - terms and conditions of this Public License, which are limited to - all Copyright and Similar Rights that apply to Your use of the - Licensed Material and that the Licensor has authority to license. - - j. Licensor means the individual(s) or entity(ies) granting rights - under this Public License. - - k. Share means to provide material to the public by any means or - process that requires permission under the Licensed Rights, such - as reproduction, public display, public performance, distribution, - dissemination, communication, or importation, and to make material - available to the public including in ways that members of the - public may access the material from a place and at a time - individually chosen by them. - - l. Sui Generis Database Rights means rights other than copyright - resulting from Directive 96/9/EC of the European Parliament and of - the Council of 11 March 1996 on the legal protection of databases, - as amended and/or succeeded, as well as other essentially - equivalent rights anywhere in the world. - - m. You means the individual or entity exercising the Licensed Rights - under this Public License. Your has a corresponding meaning. - - -Section 2 -- Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, - the Licensor hereby grants You a worldwide, royalty-free, - non-sublicensable, non-exclusive, irrevocable license to - exercise the Licensed Rights in the Licensed Material to: - - a. reproduce and Share the Licensed Material, in whole or - in part; and - - b. produce, reproduce, and Share Adapted Material. - - 2. Exceptions and Limitations. For the avoidance of doubt, where - Exceptions and Limitations apply to Your use, this Public - License does not apply, and You do not need to comply with - its terms and conditions. - - 3. Term. The term of this Public License is specified in Section - 6(a). - - 4. Media and formats; technical modifications allowed. The - Licensor authorizes You to exercise the Licensed Rights in - all media and formats whether now known or hereafter created, - and to make technical modifications necessary to do so. The - Licensor waives and/or agrees not to assert any right or - authority to forbid You from making technical modifications - necessary to exercise the Licensed Rights, including - technical modifications necessary to circumvent Effective - Technological Measures. For purposes of this Public License, - simply making modifications authorized by this Section 2(a) - (4) never produces Adapted Material. - - 5. Downstream recipients. - - a. Offer from the Licensor -- Licensed Material. Every - recipient of the Licensed Material automatically - receives an offer from the Licensor to exercise the - Licensed Rights under the terms and conditions of this - Public License. - - b. Additional offer from the Licensor -- Adapted Material. - Every recipient of Adapted Material from You - automatically receives an offer from the Licensor to - exercise the Licensed Rights in the Adapted Material - under the conditions of the Adapter's License You apply. - - c. No downstream restrictions. You may not offer or impose - any additional or different terms or conditions on, or - apply any Effective Technological Measures to, the - Licensed Material if doing so restricts exercise of the - Licensed Rights by any recipient of the Licensed - Material. - - 6. No endorsement. Nothing in this Public License constitutes or - may be construed as permission to assert or imply that You - are, or that Your use of the Licensed Material is, connected - with, or sponsored, endorsed, or granted official status by, - the Licensor or others designated to receive attribution as - provided in Section 3(a)(1)(A)(i). - - b. Other rights. - - 1. Moral rights, such as the right of integrity, are not - licensed under this Public License, nor are publicity, - privacy, and/or other similar personality rights; however, to - the extent possible, the Licensor waives and/or agrees not to - assert any such rights held by the Licensor to the limited - extent necessary to allow You to exercise the Licensed - Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this - Public License. - - 3. To the extent possible, the Licensor waives any right to - collect royalties from You for the exercise of the Licensed - Rights, whether directly or through a collecting society - under any voluntary or waivable statutory or compulsory - licensing scheme. In all other cases the Licensor expressly - reserves any right to collect such royalties. - - -Section 3 -- License Conditions. - -Your exercise of the Licensed Rights is expressly made subject to the -following conditions. - - a. Attribution. - - 1. If You Share the Licensed Material (including in modified - form), You must: - - a. retain the following if it is supplied by the Licensor - with the Licensed Material: - - i. identification of the creator(s) of the Licensed - Material and any others designated to receive - attribution, in any reasonable manner requested by - the Licensor (including by pseudonym if - designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of - warranties; - - v. a URI or hyperlink to the Licensed Material to the - extent reasonably practicable; - - b. indicate if You modified the Licensed Material and - retain an indication of any previous modifications; and - - c. indicate the Licensed Material is licensed under this - Public License, and include the text of, or the URI or - hyperlink to, this Public License. - - 2. You may satisfy the conditions in Section 3(a)(1) in any - reasonable manner based on the medium, means, and context in - which You Share the Licensed Material. For example, it may be - reasonable to satisfy the conditions by providing a URI or - hyperlink to a resource that includes the required - information. - - 3. If requested by the Licensor, You must remove any of the - information required by Section 3(a)(1)(A) to the extent - reasonably practicable. - - b. ShareAlike. - - In addition to the conditions in Section 3(a), if You Share - Adapted Material You produce, the following conditions also apply. - - 1. The Adapter's License You apply must be a Creative Commons - license with the same License Elements, this version or - later, or a BY-SA Compatible License. - - 2. You must include the text of, or the URI or hyperlink to, the - Adapter's License You apply. You may satisfy this condition - in any reasonable manner based on the medium, means, and - context in which You Share Adapted Material. - - 3. You may not offer or impose any additional or different terms - or conditions on, or apply any Effective Technological - Measures to, Adapted Material that restrict exercise of the - rights granted under the Adapter's License You apply. - - -Section 4 -- Sui Generis Database Rights. - -Where the Licensed Rights include Sui Generis Database Rights that -apply to Your use of the Licensed Material: - - a. for the avoidance of doubt, Section 2(a)(1) grants You the right - to extract, reuse, reproduce, and Share all or a substantial - portion of the contents of the database; - - b. if You include all or a substantial portion of the database - contents in a database in which You have Sui Generis Database - Rights, then the database in which You have Sui Generis Database - Rights (but not its individual contents) is Adapted Material, - - including for purposes of Section 3(b); and - c. You must comply with the conditions in Section 3(a) if You Share - all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not -replace Your obligations under this Public License where the Licensed -Rights include other Copyright and Similar Rights. - - -Section 5 -- Disclaimer of Warranties and Limitation of Liability. - - a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE - EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS - AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF - ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, - IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, - WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR - PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, - ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT - KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT - ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - - b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE - TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, - NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, - INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, - COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR - USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN - ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR - DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR - IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - - c. The disclaimer of warranties and limitation of liability provided - above shall be interpreted in a manner that, to the extent - possible, most closely approximates an absolute disclaimer and - waiver of all liability. - - -Section 6 -- Term and Termination. - - a. This Public License applies for the term of the Copyright and - Similar Rights licensed here. However, if You fail to comply with - this Public License, then Your rights under this Public License - terminate automatically. - - b. Where Your right to use the Licensed Material has terminated under - Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided - it is cured within 30 days of Your discovery of the - violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any - right the Licensor may have to seek remedies for Your violations - of this Public License. - - c. For the avoidance of doubt, the Licensor may also offer the - Licensed Material under separate terms or conditions or stop - distributing the Licensed Material at any time; however, doing so - will not terminate this Public License. - - d. Sections 1, 5, 6, 7, and 8 survive termination of this Public - License. - - -Section 7 -- Other Terms and Conditions. - - a. The Licensor shall not be bound by any additional or different - terms or conditions communicated by You unless expressly agreed. - - b. Any arrangements, understandings, or agreements regarding the - Licensed Material not stated herein are separate from and - independent of the terms and conditions of this Public License. - - -Section 8 -- Interpretation. - - a. For the avoidance of doubt, this Public License does not, and - shall not be interpreted to, reduce, limit, restrict, or impose - conditions on any use of the Licensed Material that could lawfully - be made without permission under this Public License. - - b. To the extent possible, if any provision of this Public License is - deemed unenforceable, it shall be automatically reformed to the - minimum extent necessary to make it enforceable. If the provision - cannot be reformed, it shall be severed from this Public License - without affecting the enforceability of the remaining terms and - conditions. - - c. No term or condition of this Public License will be waived and no - failure to comply consented to unless expressly agreed to by the - Licensor. - - d. Nothing in this Public License constitutes or may be interpreted - as a limitation upon, or waiver of, any privileges and immunities - that apply to the Licensor or You, including from the legal - processes of any jurisdiction or authority. - - -======================================================================= - -Creative Commons is not a party to its public -licenses. Notwithstanding, Creative Commons may elect to apply one of -its public licenses to material it publishes and in those instances -will be considered the “Licensor.” The text of the Creative Commons -public licenses is dedicated to the public domain under the CC0 Public -Domain Dedication. Except for the limited purpose of indicating that -material is shared under a Creative Commons public license or as -otherwise permitted by the Creative Commons policies published at -creativecommons.org/policies, Creative Commons does not authorize the -use of the trademark "Creative Commons" or any other trademark or logo -of Creative Commons without its prior written consent including, -without limitation, in connection with any unauthorized modifications -to any of its public licenses or any other arrangements, -understandings, or agreements concerning use of licensed material. For -the avoidance of doubt, this paragraph does not form part of the -public licenses. - -Creative Commons may be contacted at creativecommons.org. +MIT License + +Copyright (c) 2026 phoseinq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.fa.md b/PRIVACY.fa.md new file mode 100644 index 0000000..207fd4e --- /dev/null +++ b/PRIVACY.fa.md @@ -0,0 +1,145 @@ +# حریم خصوصی + +[English](PRIVACY.md) · **فارسی** + +Halo کاملاً روی دستگاهِ خودت اجرا می‌شود. نه حسابِ کاربری دارد، نه سروری، نه آنالیتیکس، نه تله‌متری و +نه گزارشِ کرش. هیچ‌چیزی از آنچه در قرص می‌بینی جایی آپلود نمی‌شود. + +این صفحه هست تا بتوانی این ادعا را **بررسی** کنی، نه اینکه فقط قبولش کنی. هر نوع داده‌ای که Halo +می‌خواند، هر چیزی که روی دیسک می‌نویسد، و **تمامِ درخواست‌های شبکه‌ای که اصلاً قادر است بزند** اینجا +فهرست شده. + +--- + +## چه چیزی از دستگاهت می‌خواند + +همه‌اش روی همان دستگاه می‌ماند. + +| چه چیزی | برای چه | از کجا | +| :-- | :-- | :-- | +| نامِ آهنگ، خواننده، کاور، موقعیتِ پخش | پنلِ مدیا | مدیا سشنِ خودِ ویندوز (همانی که فلای‌اوتِ صدا استفاده می‌کند) | +| عنوان، متن و آیکونِ نوتیف | آوردنِ نوتیف داخلِ قرص | `UserNotificationListener`ِ ویندوز | +| آرگومان‌های اجرای یک توست | تا کلیک روی بنر دقیقاً همان پیام را باز کند | دیتابیسِ نوتیفیکیشنِ خودِ ویندوز (`wpndatabase.db`) | +| کدِ تأیید داخلِ یک نوتیف | دکمهٔ **کپیِ** یک‌کلیکی | در حافظه از متنِ نوتیف پیدا می‌شود. فقط وقتی دکمه را بزنی به کلیپ‌بورد می‌رود | +| نام، حجم و پیشرفتِ دانلود | پنلِ دانلود | دیتابیسِ محلیِ دانلودِ خودِ مرورگرت | +| سطحِ باتریِ دستگاهِ بلوتوث | پنلِ باتری | APIهای بلوتوثِ ویندوز | +| وضعیتِ سشن‌های کدنویسی | پنلِ Claude Code و Codex | فایل‌های JSONی که هوک‌های خودِ آن ابزارها زیرِ `~/.claude/notch`، `~/.codex/notch` و `~/.halo/agents` می‌نویسند | +| مسیرِ فایل‌هایی که روی قرص می‌کشی | فایل تری | خودت کشیدی‌شان. فقط مسیر نگه داشته می‌شود، هیچ‌وقت محتوا | +| اینکه کدام پنجره جلوست | تا قرص همراهِ اپی که استفاده می‌کنی برود | APIِ پنجرهٔ فعالِ ویندوز. فقط شناسهٔ پروسه استفاده می‌شود | +| موقعیتِ مکانیِ دستگاهت | هوای روی بنرِ ساعتی | سرویسِ موقعیتِ خودِ ویندوز — **فقط اگر Location روشن باشد و Halo اجازه داشته باشد**. اگر خاموش باشد یا اجازه نداشته باشد، Halo دیگر نمی‌پرسد و به شهرِ تایم‌زونت برمی‌گردد | + +--- + +## چه چیزی روی دیسک می‌نویسد + +همه‌چیز در `%LOCALAPPDATA%\Halo\` است. پاک‌کردنِ آن پوشه Halo را کاملاً از نو می‌کند. + +- `offset`، `pinned`، `scale`، `capturable` — اینکه قرص را کجا گذاشته‌ای و چطور دوستش داری +- `tray.txt` — مسیرهایی که همین حالا در فایل تری هستند +- `notif-seen.txt` — شناسهٔ آخرین نوتیفِ نشان‌داده‌شده، تا ری‌استارت اکشن‌سنترت را دوباره پخش نکند +- `banner-orig.tsv` — **تنظیمِ اصلیِ بنرِ هر اپ، پیش از آنکه Halo عوضش کند** (پایین‌تر توضیح داده شده) +- `limit-fired.txt`، `usage-cache.json`، `codex-limits-cache.json` — کدام هشدارها زده شده‌اند و آخرین اعدادِ مصرف +- `downloaders.tsv` — دفترداریِ دانلود +- `*-debug.txt` — تشخیصِ محلی + +دربارهٔ فایل‌های تشخیصی باید دقیق بود، چون به نوتیف‌هایت مربوط‌اند: `notif-debug.txt` ثبت می‌کند +**کدام اپ** نوتیف فرستاده و عنوان و متنش **چند کاراکتر** بوده — هیچ‌وقت خودِ متن را. یک خطش این‌شکلی +است و تمامش همین است: + +``` +15:37:26 toast 67750: aumid='Logi.GHUB.Systray' app='Logitech G HUB' t=14 b=22 +``` + +هیچ‌کدام از این فایل‌ها هرگز جایی فرستاده نمی‌شوند. + +--- + +## تنها تغییری که در رجیستری می‌دهد + +وقتی Halo یک توست را داخلِ قرص می‌آورد، بنرِ خودِ ویندوز را برای آن اپ ساکت می‌کند تا یک چیز دو بار +تحویلت داده نشود. این کار را با صفر کردنِ `ShowBanner` برای آن اپ زیرِ +`HKCU\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings` انجام می‌دهد. + +اول مقدارِ **اصلیِ** هر اپ را یادداشت می‌کند و تغییر کاملاً برگشت‌پذیر است: + +``` +Halo.App.exe --restore-notifications +``` + +خودِ حذف‌کننده این را برایت اجرا می‌کند، پس پاک‌کردنِ Halo نوتیفیکیشنِ همهٔ اپ‌ها را همان‌طور که پیدا +کرده بود برمی‌گرداند. + +--- + +## تمامِ درخواست‌های شبکه‌ای که می‌تواند بزند + +Halo هیچ درخواستی خارج از این فهرست نمی‌زند. + +| مقصد | کِی | چه چیزی فاش می‌کند | +| :-- | :-- | :-- | +| `www.google.com/generate_204` | بررسیِ اتصال، وقتی پنلِ یک سشنِ کدنویسی باز است | هیچ. این نقطهٔ استانداردِ بررسیِ اتصال است و پاسخِ خالی برمی‌گرداند | +| `api.anthropic.com` — `/api/oauth/usage` و `/v1/messages` | سقفِ مصرفِ Claude Code و در دسترس بودنِ API | اعتبارنامهٔ **خودِ تو**، از `~/.claude/.credentials.json` — همان توکنی که خودِ Claude Code استفاده می‌کند، و فقط به Anthropic | +| `chatgpt.com/backend-api/codex/responses` | اینکه Codex در دسترس هست یا نه | یک پروبِ در دسترس بودن | +| `ipwho.is` | تا نشان دهد اتصالت از کدام کشور بیرون می‌رود | **آی‌پیِ عمومی‌ات**، به‌ناچار. این یک سرویسِ شخصِ ثالث است | +| `api.ipapi.is` | فقط وقتی موس را روی بلوکِ خروجی می‌بری، تا بگوید آن آدرس دیتاسنتری است، وی‌پی‌انِ شناخته‌شده است، یا پرچم‌خورده | **آی‌پیِ عمومی‌ات**. این یک سرویسِ شخصِ ثالث است. برای هر آدرس یک بار فرستاده و بعد کش می‌شود، پس هاورِ دوباره درخواستِ تازه‌ای ندارد | +| `bash.ws` — `/id`، شش جست‌وجوی `..bash.ws` و `/dnsleak/test/` | فقط وقتی موس را روی بلوکِ خروجی می‌بری، تا آزمایش کند جست‌وجوهای DNS از همان دری بیرون می‌روند که ترافیکت | **اینکه چه resolverهایی برایت جواب می‌دهند**، و آی‌پیِ عمومی‌ات. شخصِ ثالث است و افشایش از دو موردِ بالا بیشتر است: تمامِ مکانیزم این است که نیم‌سرورشان ببیند کدام resolver سراغش آمده. برای هر آدرس یک بار، بعد کش | +| `flagcdn.com` | تصویرِ پرچمِ همان کشور | کدِ دوحرفیِ کشور | +| `displaycatalog.mp.microsoft.com` | نام و تصویرِ یک نصبِ در جریانِ مایکروسافت استور | شناسهٔ محصولِ استور | +| `geocoding-api.open-meteo.com` و `api.open-meteo.com` | هوای روی بنرِ ساعتی، هر نیم ساعت یک بار تازه می‌شود | **مختصات.** اگر Location ویندوز روشن باشد و Halo اجازه داشته باشد، مختصاتِ **خودِ دستگاهت** است، با دقتِ حدود ۱۱ متر. وگرنه مختصاتِ شهری است که از تایم‌زونت درمی‌آید — «Asia/Tehran» می‌شود «Tehran» — که به پهنای یک شهر است. نامِ شهر هم یک‌بار برای پیدا کردنِ همان مختصات فرستاده می‌شود | +| `127.0.0.1` | کنترلِ پخشِ VLC | هیچ — از دستگاهت بیرون نمی‌رود | + +**`ipwho.is`، `api.ipapi.is` و `bash.ws` تنها درخواست‌هایی هستند که چیزی دربارهٔ تو به یک شخصِ ثالث +می‌گویند.** هر سه آی‌پیِ عمومی‌ات را فاش می‌کنند، دقیقاً همان‌طور که بازکردنِ هر صفحهٔ وبی این کار را +می‌کند. `ipwho.is` فقط وقتی پنلِ سشنِ کدنویسی باز است اجرا می‌شود و حداکثر هر پنج دقیقه یک بار. آن دوی +دیگر فقط وقتی واقعاً **موس را روی بلوکِ خروجی ببری**، برای هر آدرس یک بار، و جواب تا عوض‌شدنِ آدرس کش +می‌ماند — سؤالی که با اشاره‌کردن می‌پرسی، پس هرکدام دقیقاً یک جست‌وجو خرج دارد. + +`api.ipapi.is` عمداً روی HTTPS پرسیده می‌شود؛ سرویس‌های دیگر همین پرچم‌ها را روی HTTP بی‌رمز می‌دهند، و +پرسیدنِ «آیا خروجی‌ام خصوصی است» از کانالی که شبکهٔ محلی می‌تواند بخواند و دستکاری کند، معاملهٔ درستی +نیست. + +**`bash.ws` بندِ جداگانهٔ خودش را می‌خواهد**، چون تستِ نشتیِ DNS بی‌سروصدا شدنی نیست. از داخلِ دستگاهِ +خودت هیچ راهی نیست که ببینی واقعاً کدام resolver برایت جواب می‌دهد — تنها راه این است که نام‌هایی را +جست‌وجو کنی که نیم‌سرورشان در حال تماشاست، و بعد بخوانی چه resolverهایی سراغش آمده‌اند. پس این تست +ناگزیر به `bash.ws` می‌گوید چه کسی نام‌هایت را resolve می‌کند. تمامِ کارکردش همین است، و دقیقاً به همین +دلیل هیچ‌وقت خودبه‌خود اجرا نمی‌شود: هاور نباشد، تستی هم نیست. + +**Open-Meteo** کلید نمی‌خواهد و هیچ‌چیزی که تو را مشخص کند برایش فرستاده نمی‌شود — نه نام، نه شناسه، نه +حساب. چیزی که فرستاده می‌شود یک نقطه است تا هوایش را بدهد، و آن نقطه دقیقاً همان‌قدر دقیق است که خودت +اجازه داده‌ای: + +- **Location روشن و Halo مجاز** — مختصاتِ خودِ دستگاهت، با دقتِ حدود ۱۱ متر. Halo حداکثر هر ده دقیقه یک + فیکس از ویندوز می‌گیرد، آن هم فقط روی همان تازه‌سازیِ نیم‌ساعتهٔ هوا. +- **Location خاموش یا Halo غیرمجاز** — شهری که از تایم‌زونِ تنظیم‌شدهٔ ویندوز درمی‌آید؛ درشت‌ترین اطلاعِ + مکانیِ روی دستگاه و چیزی که خودت انتخابش کرده‌ای. Halo قبل از پرسیدن، کلیدِ سیستم را می‌خواند، پس یک + اپِ غیرمجاز هیچ پرامپتی درنمی‌آورد و در آن سشن دیگر نمی‌پرسد. + +**این را در ویندوز کنترل می‌کنی، نه در Halo**: Settings ← Privacy & security ← Location. خاموشش کن، +بنر همچنان کار می‌کند؛ به پهنای یک شهر به‌جای یک خیابان. Halo هیچ‌وقت برای هوا از آن جست‌وجوی آی‌پیِ +بالا استفاده نمی‌کند. + +اگر هرکدام از این معامله‌ها به‌نظرت نمی‌ارزد، در ایشوها بگو — همه‌شان گزینهٔ خوبی برای یک کلید +خاموش/روشن‌اند. + +--- + +## چه کارهایی هیچ‌وقت نمی‌کند + +- نه حسابِ کاربری و نه ورود — اصلاً چیزی برای واردشدن وجود ندارد. +- نه آنالیتیکس، نه تله‌متری، نه آمارِ استفاده و نه گزارشِ کرش، در هیچ بیلدی. +- متنِ نوتیف، نامِ آهنگ، نامِ فایل، نامِ دانلود و محتوای کلیپ‌بورد **هرگز از دستگاهت بیرون نمی‌روند**. +- **نه بررسیِ آپدیت، نه دانلودِ پس‌زمینه.** Halo برای نسخهٔ جدید به هیچ‌جا زنگ نمی‌زند؛ اصلاً آپدیتری + ندارد. همان‌طور که نصبش کردی، به‌روزش می‌کنی. +- هیچ‌چیزی برای سازنده یا `pvboy.dev` فرستاده نمی‌شود. اصلاً سروری پشتِ Halo وجود ندارد. + +--- + +## چطور خودت بررسی کنی + +سورس همین‌جاست و بیلد هم از روی همین است. هر درخواستِ بیرونیِ جدولِ بالا یک `grep` فاصله دارد: + +``` +grep -rn "https\?://" --include="*.cs" src/ +``` + +چیزی جا افتاده یا اشتباه است؟ [یک ایشو باز کن](https://github.com/phoseinq/Halo/issues). diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..c998308 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,144 @@ +# Privacy + +**English** · [فارسی](PRIVACY.fa.md) + +Halo runs entirely on your machine. There is no Halo account, no Halo server, no analytics, no +telemetry and no crash reporting. Nothing that appears in the pill is uploaded anywhere. + +This page exists so you can check that claim rather than take it. It lists every kind of data Halo +reads, everything it writes to disk, and **every network request it is capable of making**. + +--- + +## What Halo reads from your machine + +All of it stays on your machine. + +| What | What it is for | Where it comes from | +| :-- | :-- | :-- | +| Track title, artist, artwork, position | the media panel | Windows' own media session (the same one the volume flyout uses) | +| Notification title, body, app icon | mirroring toasts into the pill | Windows' `UserNotificationListener` | +| A toast's launch arguments | so clicking a banner opens the exact message | Windows' own notification database (`wpndatabase.db`) | +| A verification code inside a notification | the one-click **Copy** button | matched in memory from the notification's text. It reaches your clipboard only when you press the button | +| Download name, size and progress | the download panel | your browser's own local download database | +| Bluetooth device battery level | the battery panel | Windows Bluetooth APIs | +| Coding-session state | the Claude Code / Codex panels | JSON files those tools' own hooks write under `~/.claude/notch`, `~/.codex/notch` and `~/.halo/agents` | +| Paths of files you drag onto the pill | the file tray | you dragged them there. Only the paths are kept, never the contents | +| Which window is in front | so the pill can follow the app you're using | Windows' foreground-window API. Only the process id is used | +| Your device's location | the weather on the hourly banner | Windows' own location service — **only if location is switched on and Halo is allowed to use it**. If it is off, or Halo is denied, Halo never asks again and falls back to your timezone's city | + +--- + +## What Halo writes to disk + +Everything lives in `%LOCALAPPDATA%\Halo\`. Deleting that folder resets Halo completely. + +- `offset`, `pinned`, `scale`, `capturable` — where you put the pill and how you like it +- `tray.txt` — the paths currently in the file tray +- `notif-seen.txt` — the id of the last notification shown, so restarts don't replay your Action Center +- `banner-orig.tsv` — **each app's original Windows banner setting before Halo changed it** (see below) +- `limit-fired.txt`, `usage-cache.json`, `codex-limits-cache.json` — which alerts have fired, and the last usage numbers +- `downloaders.tsv` — download bookkeeping +- `*-debug.txt` — local diagnostics + +The diagnostics are worth being specific about, because they concern your notifications: +`notif-debug.txt` records **which app** sent a notification and **how many characters** its title and +body had — never the text. A line looks like this, and that is the whole of it: + +``` +15:37:26 toast 67750: aumid='Logi.GHUB.Systray' app='Logitech G HUB' t=14 b=22 +``` + +None of these files are ever transmitted anywhere. + +--- + +## The one registry change Halo makes + +When Halo mirrors a toast, it silences Windows' own banner for that app so you aren't told the same +thing twice. It does that by setting `ShowBanner` to `0` for that app under +`HKCU\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings`. + +It writes down each app's **original** value first, and the change is fully reversible: + +``` +Halo.App.exe --restore-notifications +``` + +The uninstaller runs that for you, so removing Halo puts every app's notifications back the way it +found them. + +--- + +## Every network request Halo can make + +Halo makes no request that is not on this list. + +| Endpoint | When | What it discloses | +| :-- | :-- | :-- | +| `www.google.com/generate_204` | connectivity check, while a coding-session panel is live | nothing. This is the standard connectivity-check endpoint, chosen because it returns an empty response | +| `api.anthropic.com` — `/api/oauth/usage` and `/v1/messages` | your Claude Code usage limits and whether the API is reachable | **your own** Claude credentials, read from `~/.claude/.credentials.json` — the same token Claude Code itself uses, sent only to Anthropic | +| `chatgpt.com/backend-api/codex/responses` | whether Codex is reachable | a reachability probe | +| `ipwho.is` | to show which country your connection is leaving from | **your public IP address**, unavoidably. This is a third party | +| `api.ipapi.is` | only while you hover the exit block, to say whether that address looks like a datacenter, a known vpn, or a flagged one | **your public IP address**. This is a third party. Sent once per address and then cached, so hovering repeatedly costs no further requests | +| `bash.ws` — `/id`, six lookups of `..bash.ws`, and `/dnsleak/test/` | only while you hover the exit block, to test whether your DNS lookups leave by the same exit as your traffic | **which resolvers answer for you**, and your public IP. This is a third party, and it is a wider disclosure than the two above: the whole mechanism is that their nameserver watches which resolver comes asking. Once per address, then cached | +| `flagcdn.com` | the flag image for that country | the two-letter country code | +| `geocoding-api.open-meteo.com` and `api.open-meteo.com` | the weather on the hourly banner, refreshed every half hour | **coordinates.** If Windows location is on and Halo is allowed, those are **your device's own coordinates**, to about 11 m. Otherwise they are the coordinates of the city from your timezone — "Asia/Tehran" becomes "Tehran" — which is a whole city wide. The city name is also sent once, to look it up | +| `displaycatalog.mp.microsoft.com` | the name and art of a Microsoft Store install in progress | the Store product id | +| `127.0.0.1` | VLC playback controls | nothing — it never leaves your machine | + +**`ipwho.is`, `api.ipapi.is` and `bash.ws` are the only requests that tell a third party anything about +you.** All three disclose your public IP the same way opening any web page does. `ipwho.is` runs only +while a coding-session panel is open, and at most once every five minutes. The other two run only when +you actually **hover the exit block**, once per address, cached until the address changes — they answer a +question you asked by pointing at it, so each costs exactly one lookup. + +`api.ipapi.is` is asked over HTTPS deliberately: other providers serve the same flags over plaintext +HTTP, and asking "is my exit private" over a channel the local network can read and rewrite is the wrong +trade. + +**`bash.ws` deserves its own paragraph**, because a DNS leak test cannot be done quietly. There is no way +to see which resolver actually answers for you from inside your own machine — the only way is to look up +names under a domain whose nameserver is watching, and read back which resolvers came asking. So the test +necessarily tells `bash.ws` who resolves your names. That is the entire point of it, and it is why it +never runs on its own: no hover, no test. + +**Open-Meteo** needs no key and is sent nothing that identifies you — no name, no id, no account. What +it is sent is a point to fetch the weather for, and that point is as precise as you have allowed: + +- **Location switched on and Halo allowed** — your device's own coordinates, at roughly 11 m. Halo asks + Windows for a fix at most every ten minutes, and only on the half-hourly weather refresh. +- **Location off, or Halo denied** — the city from the timezone Windows is already set to, which is the + coarsest location fact on the machine and one you chose yourself. Halo reads the system switch before + asking, so a denied app never triggers a prompt and never asks again in that session. + +**You control this in Windows, not in Halo**: Settings → Privacy & security → Location. Turn it off and +the banner keeps working, one city wide instead of one street. Halo never uses the exit-IP lookup above +for the weather. + +If any of these trades isn't worth it to you, say so in an issue — all of them are good candidates for a +switch. + +--- + +## What Halo never does + +- No account and no sign-in — there is nothing to sign in to. +- No analytics, telemetry, usage statistics or crash reporting, in any build. +- Notification text, media titles, file names, download names and clipboard contents **never leave + your machine**. +- **No update checks and no background downloads.** Halo does not phone home for new versions; it has + no updater at all. You update it the way you installed it. +- Nothing is sent to the author or to `pvboy.dev`. There is no server behind Halo at all. + +--- + +## Checking any of this yourself + +The source is here and builds from it. Every outbound request in the table above is one `grep` away: + +``` +grep -rn "https\?://" --include="*.cs" src/ +``` + +Something missing or wrong? [Open an issue](https://github.com/phoseinq/Halo/issues). diff --git a/README.fa.md b/README.fa.md new file mode 100644 index 0000000..42d68f9 --- /dev/null +++ b/README.fa.md @@ -0,0 +1,144 @@ +
+ +**یک قرصِ شیشه‌ای برای ویندوز؛ همان Dynamic Islandی که همیشه جایش روی دسکتاپ خالی بود.** + +
+ +[![Release](https://img.shields.io/github/v/release/phoseinq/Halo?label=release&color=c49b04&logo=github&logoColor=white)](https://github.com/phoseinq/Halo/releases/latest) +[![Platform](https://img.shields.io/badge/Windows-11-0078D6?logo=windows11&logoColor=white)](https://github.com/phoseinq/Halo/releases/latest) +[![Built with](https://img.shields.io/badge/C%23-.NET%209-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com) +[![Downloads](https://img.shields.io/github/downloads/phoseinq/Halo/total?label=downloads&color=2CA5E0&logo=github&logoColor=white)](https://github.com/phoseinq/Halo/releases) +[![License](https://img.shields.io/badge/License-MIT-c49b04.svg)](LICENSE) + +
+ +[English](README.md) · **فارسی** + +[⬇️ دانلود](https://github.com/phoseinq/Halo/releases/latest) · [گزارش باگ](https://github.com/phoseinq/Halo/issues) · [پیشنهاد قابلیت](https://github.com/phoseinq/Halo/issues) + +
+ +
+ +
+ +Halo — یک قرصِ شیشه‌ای برای ویندوز، همان Dynamic Islandی که همیشه جایش روی دسکتاپ خالی بود + +

+ +### 👈 [قبل از نصب امتحانش کن](https://pvboy.dev/assets/blog/halo-live.html) + +بدون نصب، مستقیم داخل مرورگر. روی قرص موس ببر، پنل رو باز کن، نوار زمان رو جابه‌جا کن یا بین اپ‌ها سوییچ کن تا ببینی حس کار کردن باهاش چطوریه. + +
+ +
+ +یه قرص شیشه‌ای بالای صفحه که هر وقت چیزی برای نمایش داشته باشه ظاهر میشه و وقتی کاری نداره دوباره +جمع میشه. نه پنجره اضافه، نه شلوغی روی دسکتاپ؛ فقط اطلاعاتی که همون لحظه لازم داری. + +
+ +

⬇️ دانلود

+ +
+ +### [⬇️ دانلود برای Windows 11](https://github.com/phoseinq/Halo/releases/latest) + +Windows 11 · x64 · نصب فقط برای همین کاربر · بدون درخواست دسترسی Administrator + +اگر نصب معمولی می‌خوای، `DynamicWinSetup.exe` رو دانلود کن. اگر هم نسخه قابل‌حمل ترجیح میدی، `DynamicWinPortable.zip` آماده است. + +
+ +
+ +

🎵 مدیا

+ +
+بازشدنِ قرص به پنلِ مدیا +
+ +وقتی چیزی در حال پخشه، قرص زنده میشه. + +در حالت بسته فقط Waveform خروجی واقعی سیستم رو می‌بینی؛ اما با باز شدنش، کنترل کامل مدیا در اختیارت +قرار می‌گیره؛ کاور، اسم آهنگ، نوار زمان، کنترل صدا، دکمه‌های پخش و برای ویدیو هم جلو و عقب بردن، +تغییر سرعت و زیرنویس. + +همه اطلاعات مستقیماً از Media Session خود ویندوز گرفته میشه؛ بنابراین با بیشتر برنامه‌هایی که ازش +پشتیبانی می‌کنن کار می‌کنه. + +
+ +

🔔 اعلان‌ها

+ +
+کپیِ کدِ تأیید مستقیم از روی بنر +
+ +اعلان‌ها مستقیم داخل Halo نمایش داده میشن. + +آیکون واقعی برنامه، متن اعلان و همه چیز داخل خود قرص دیده میشه و بنر ویندوز هم بی‌صدا میشه تا یک +اعلان دوبار جلوی چشمت ظاهر نشه. + +اگر اعلان شامل کد تأیید باشه، فقط روی دکمه **Copy** کلیک کن تا همون لحظه داخل کلیپ‌بورد کپی بشه. + +
+ +

📁 File Tray

+ +
+کشیدنِ یک فایل روی قرص و بیرون‌کشیدنش +
+ +فایل‌ها رو موقت کنار دستت نگه دار. + +کافیه فایل رو روی Halo بندازی؛ هر وقت خواستی دوباره از همونجا بکش و داخل هر برنامه یا هر پنجره‌ای +رهاش کن. انگار یه فضای Drag & Drop همیشه دم دستت داری. + +
+ +

🤖 پنل‌های هوش مصنوعی

+ +
+پنلِ Claude Code +
+ +برای هر سشن Claude Code یا Codex یک پنل زنده ساخته میشه. + +می‌بینی الان روی چه کاری مشغوله، چقدر Context باقی مونده، محدودیت‌های زمانی چقدره و اگر خواستی همون +لحظه اجرای پرامپت رو متوقف می‌کنی. + +نمودار شبکه فقط سرعت اینترنتت رو نشون نمیده؛ مسیر ارتباط با سرورهای Anthropic رو هم بررسی می‌کنه تا +راحت‌تر بفهمی مشکل از اینترنت خودته یا از سمت سرویس. + +روی اطلاعات شبکه هم که موس ببری، جزئیاتی مثل کشور، ASN، تأخیر، Packet Loss و حتی تست واقعی DNS Leak +نمایش داده میشه. + +اگر ابزار جدیدی اضافه بشه، فقط با یک فایل JSON می‌تونه پنل مخصوص خودش رو داشته باشه. + +
+ +

✨ امکانات دیگه

+ +- ⬇️ **دانلودها** — نمایش پیشرفت واقعی دانلودهای Chrome و Edge، همراه با دکمه Cancel که واقعاً دانلود رو متوقف می‌کنه. +- 🔋 **باتری دستگاه‌های بلوتوث** — درصد باتری هدفون، دسته بازی یا گوشی متصل رو مستقیم داخل Halo ببین. +- ⚠️ **هشدارهای هوشمند** — باتری، CPU، رم یا اینترنت فقط وقتی لازم باشه بهت اطلاع داده میشه؛ نه اینکه مدام مزاحمت بشه. +- 📌 **Pin Mode** — Halo حتی روی برنامه‌های Fullscreen هم می‌مونه و اگر بخوای داخل Screen Recording هم نمایش داده میشه. + +
+ +> [!NOTE] +> Halo بازنویسی کامل [DynamicWin](https://github.com/FlorianButz/DynamicWin) از Florian Butz است که از صفر نوشته شده و هیچ بخشی از کد پروژه اصلی را استفاده نمی‌کند. این پروژه با **.NET 9** توسعه داده شده است. + +
+ +--- + +
+ +⭐ **اگر از Halo خوشت اومد، با یک Star از پروژه حمایت کن.** + +MIT License · ساخته شده توسط phoseinq · pvboy.dev + +
diff --git a/README.md b/README.md index 47fb08d..03fa546 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,127 @@ -# DynamicWin - -

- - - -

- -

- animated -

- -

DynamicWin by Florian Butz is licensed under CC BY-SA 4.0

- -### What is it? -A [Dynamic Island](https://support.apple.com/de-de/guide/iphone/iph28f50d10d/ios) inspired Windows App that brings in a bunch of features like widgets or a file tray that works like a clipboard. -Similar to dynamic notches that you can find on macOS like [NotchNook](https://lo.cafe/notchnook), this application brings the concept on Windows devices to life. - -This project was made possible with [**FenUI**](https://github.com/FlorianButz/fenUISharp) - -# Features -- A media controller -- - Favorites -- A calendar with Google Calendar integration -- File Tray -- - Files inside the Tray can be executed (e.g. a shortcut) with a double click -- - Files can be shared using the Windows File Share dialog -- - Files can be stored in the Tray for later use -- - ~~Shaking a currently dragged file will open a quick drop popup~~ (This had to be cut due to massive performance issues) -- Bluetooth view which shows the connected device and battery -- Activity system (Currently includes media player and BT view) -- - Spring notches (let you see or open an action which is not the current view) -- Swapping between views can be done by scrolling -- Auto updater - -> [!NOTE] This repository currently only exists to host the releases. There is no source code here. \ No newline at end of file +
+ +**A glass notch for Windows — the Dynamic Island your desktop never got.** + +
+ +[![Release](https://img.shields.io/github/v/release/phoseinq/Halo?label=release&color=c49b04&logo=github&logoColor=white)](https://github.com/phoseinq/Halo/releases/latest) +[![Platform](https://img.shields.io/badge/Windows-11-0078D6?logo=windows11&logoColor=white)](https://github.com/phoseinq/Halo/releases/latest) +[![Built with](https://img.shields.io/badge/C%23-.NET%209-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com) +[![Downloads](https://img.shields.io/github/downloads/phoseinq/Halo/total?label=downloads&color=2CA5E0&logo=github&logoColor=white)](https://github.com/phoseinq/Halo/releases) +[![License](https://img.shields.io/badge/License-MIT-c49b04.svg)](LICENSE) + +
+ +**English** · [فارسی](README.fa.md) + +[⬇️ Download](https://github.com/phoseinq/Halo/releases/latest) · [Report a bug](https://github.com/phoseinq/Halo/issues) · [Request a feature](https://github.com/phoseinq/Halo/issues) + +
+ +
+ +
+ +Halo — a glass notch for Windows, the Dynamic Island your desktop never got + +

+ +### 👉 [Try the pill in your browser](https://pvboy.dev/assets/blog/halo-live.html) + +No install. Hover it, open the panel, drag the seek bar, tap the circle to swap app. + +
+ +
+ +A pill of glass at the top of your screen. It comes forward when there is something to say and +folds away when there is not. No app to open, nothing covering your work. + +
+ +

⬇️ Install

+ +
+ +### [⬇️ Download for Windows](https://github.com/phoseinq/Halo/releases/latest) + +Windows 11 · x64 · per-user, no admin prompt + +Take `DynamicWinSetup.exe` to install it, or `DynamicWinPortable.zip` to just run it. + +
+ +
+ +

🎵 Media

+ +
+The pill opening into the media panel +
+ +Collapsed, a waveform off the real system output. Open, the whole player — art, track, seek, +volume, transport, plus ±10s, speed and subtitles for video. It reads Windows' own media session, +so it works with whatever is already playing. + +
+ +

🔔 Notifications

+ +
+A verification code copied straight from the banner +
+ +Every toast lands in the pill with the real app icon, and the native banner goes quiet so nothing +is said twice. If it carries a **verification code**, the pill lifts it onto a button — one click +to copy. + +
+ +

📁 File tray

+ +
+A file dragged onto the pill and back out again +
+ +Drop files onto the pill and it holds them. Drag them back out into any window later — a different +app, a different desktop, twenty minutes on. + +
+ +

🤖 Coding sessions

+ +
+The Claude Code panel +
+ +A live panel per **Claude Code** and **Codex** session: what it is doing, context left, your 5-hour +and weekly limits, and a Cancel that stops the running prompt. The graph tracks both your internet +and the path to Anthropic, so when things stall you can see whose side it is. Point at the exit and +it turns into an audit: country, ASN, latency and loss, and a real DNS leak test. Any tool can join +by writing a small JSON file. + +
+ +

…and the quiet ones

+ +- ⬇️ **Downloads** — real progress in Chrome and Edge, and a Cancel that actually cancels. +- 🔋 **Bluetooth battery** — connect headphones, a controller or your phone and the pill shows the level. +- ⚠️ **Alerts** — battery, CPU, RAM and internet, each fired once when it happens rather than nagged. +- 📌 **Pin** — keep it above fullscreen apps. Hold the pushpin to make it visible in screen recordings too. + +
+ +> [!NOTE] +> Halo is a from-scratch rewrite of [DynamicWin](https://github.com/FlorianButz/DynamicWin) by Florian Butz — no upstream code. Built with .NET 9. + +
+ +--- + +
+ +⭐ **If you install it and end up liking the little assistant, support the project with a star.** + +MIT License · made by phoseinq · pvboy.dev + +
diff --git a/ReadmeFiles/agents-2.png b/ReadmeFiles/agents-2.png new file mode 100644 index 0000000..13230b4 Binary files /dev/null and b/ReadmeFiles/agents-2.png differ diff --git a/ReadmeFiles/banner-2.svg b/ReadmeFiles/banner-2.svg new file mode 100644 index 0000000..8f3b87a --- /dev/null +++ b/ReadmeFiles/banner-2.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Halo + + A glass notch for Windows — the Dynamic Island your desktop never got + + it shows what's playing, lifts the 2FA code out of a toast, and carries your files between windows + + + + MEDIA · NOTIFICATIONS · FILE TRAY · DOWNLOADS · CLAUDE & CODEX + + WINDOWS 11 · X64 · MIT · NO ACCOUNT, NO SERVER, NO TELEMETRY + + diff --git a/ReadmeFiles/banner-fa-2.svg b/ReadmeFiles/banner-fa-2.svg new file mode 100644 index 0000000..4b0ccfa --- /dev/null +++ b/ReadmeFiles/banner-fa-2.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Halo + + ⁧یک قرصِ شیشه‌ای برای ویندوز — همان Dynamic Islandی که دسکتاپ هیچ‌وقت نداشت⁩ + + ⁧آهنگی که پخش می‌شود را نشان می‌دهد، کدِ تأیید را از دلِ نوتیف بیرون می‌کشد، و فایل‌هایت را بین پنجره‌ها می‌برد⁩ + + + + ⁧مدیا · نوتیفیکیشن · فایل‌تری · دانلود · Claude و Codex⁩ + + ⁧ویندوز ۱۱ · x64 · MIT · بدونِ حساب، بدونِ سرور، بدونِ تله‌متری⁩ + + diff --git a/ReadmeFiles/copy-code.gif b/ReadmeFiles/copy-code.gif new file mode 100644 index 0000000..d6f4ba5 Binary files /dev/null and b/ReadmeFiles/copy-code.gif differ diff --git a/ReadmeFiles/media.gif b/ReadmeFiles/media.gif new file mode 100644 index 0000000..046c709 Binary files /dev/null and b/ReadmeFiles/media.gif differ diff --git a/ReadmeFiles/preview.gif b/ReadmeFiles/preview.gif index 233cff3..1aac2ca 100644 Binary files a/ReadmeFiles/preview.gif and b/ReadmeFiles/preview.gif differ diff --git a/ReadmeFiles/tray.gif b/ReadmeFiles/tray.gif new file mode 100644 index 0000000..c3e9acd Binary files /dev/null and b/ReadmeFiles/tray.gif differ diff --git a/scripts/verify-public-source.ps1 b/scripts/verify-public-source.ps1 new file mode 100644 index 0000000..7cfec83 --- /dev/null +++ b/scripts/verify-public-source.ps1 @@ -0,0 +1,150 @@ +param( + [switch]$SelfTest, + [switch]$CommentsAdvisory, + # walk the filesystem instead of `git ls-files` -- the release script checks a staged copy of the tree + # that is not a git repository yet, so the gate can run before the push rather than after it + [switch]$Filesystem, + [string]$Root = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' + +function New-Violation { + param([string]$Rule, [string]$File, [int]$Line, [string]$Message) + [pscustomobject]@{ Rule = $Rule; File = $File; Line = $Line; Message = $Message } +} + +function Get-PolicyViolations { + param( + [Parameter(Mandatory)] + [string]$SourceRoot, + [switch]$UseFilesystem + ) + + $violations = [System.Collections.Generic.List[object]]::new() + + if ($UseFilesystem) { + $sourceFiles = Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'src') -Recurse -Filter '*.cs' | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | + ForEach-Object { $_.FullName } + } + else { + $sourceFiles = git -C $SourceRoot ls-files -- 'src/*.cs' 'src/**/*.cs' | + ForEach-Object { Join-Path $SourceRoot $_ } + } + + foreach ($file in $sourceFiles) { + $relative = [IO.Path]::GetRelativePath($SourceRoot, $file) + $lineNumber = 0 + + foreach ($line in [IO.File]::ReadLines($file)) { + $lineNumber++ + + if ($line.Contains("`t")) { + $violations.Add((New-Violation 'tab' $relative $lineNumber 'tab indentation')) + } + + if ($line -match '^\s*(//|/\*|\*|\*/)') { + $violations.Add((New-Violation 'comment' $relative $lineNumber 'shipped source comment')) + } + } + } + + $projectFiles = if ($UseFilesystem) { + Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'src') -Recurse -Filter '*.csproj' | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | + ForEach-Object { $_.FullName } + } + else { + git -C $SourceRoot ls-files -- 'src/*.csproj' 'src/**/*.csproj' | + ForEach-Object { Join-Path $SourceRoot $_ } + } + + foreach ($projectFile in $projectFiles) { + [xml]$project = Get-Content -LiteralPath $projectFile -Raw + $relative = [IO.Path]::GetRelativePath($SourceRoot, $projectFile) + + foreach ($reference in $project.Project.ItemGroup.PackageReference) { + $name = [string]$reference.Include + if ($name -and $name -ne 'System.Drawing.Common') { + $violations.Add((New-Violation 'package' $relative 0 "production package '$name' is not allowed")) + } + } + } + + return $violations +} + +if ($SelfTest) { + $tempRoot = Join-Path ([IO.Path]::GetTempPath()) "halo-policy-$([guid]::NewGuid().ToString('N'))" + + try { + $sourceDir = Join-Path $tempRoot 'src/Test' + [IO.Directory]::CreateDirectory($sourceDir) | Out-Null + [IO.File]::WriteAllText( + (Join-Path $sourceDir 'Bad.cs'), + "`tclass Bad { }`r`n// shipped comment`r`n" + ) + [IO.File]::WriteAllText( + (Join-Path $sourceDir 'Test.csproj'), + '' + ) + + $found = Get-PolicyViolations -SourceRoot $tempRoot -UseFilesystem + foreach ($expected in @('tab', 'comment', 'package')) { + if (-not ($found.Rule -contains $expected)) { + throw "self-test did not detect rule: $expected" + } + } + + $hard = @($found | Where-Object { $_.Rule -ne 'comment' }) + if ($hard.Count -lt 2) { + throw 'self-test expected the tab and package rules to survive the advisory split' + } + + Write-Host 'Policy self-test passed.' + } + finally { + if ($tempRoot.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase) -and + [IO.Directory]::Exists($tempRoot)) { + [IO.Directory]::Delete($tempRoot, $true) + } + } + + exit 0 +} + +$policyViolations = Get-PolicyViolations -SourceRoot ([IO.Path]::GetFullPath($Root)) -UseFilesystem:$Filesystem + +# On a pull request the comment rule is reported, not enforced. This repository is a mechanically stripped +# mirror of a comment-bearing tree, so shipped source carries no comments by construction -- but that is +# our publishing mechanism, not something an outside patch should be failed for. Rejecting a change +# because it explains itself teaches the wrong habit, and three of the six defects in the last outside +# patch here came from load-bearing comments having been stripped away before the contributor saw them. +# Tabs and the package allowlist stay hard everywhere: those are real policy, not mirroring artefacts. +$advisory = @() +$fatal = @($policyViolations) + +if ($CommentsAdvisory) { + $advisory = @($policyViolations | Where-Object { $_.Rule -eq 'comment' }) + $fatal = @($policyViolations | Where-Object { $_.Rule -ne 'comment' }) +} + +foreach ($item in $advisory) { + Write-Host "::warning file=$($item.File),line=$($item.Line)::$($item.Message)" +} + +if ($fatal.Count -gt 0) { + $lines = $fatal | ForEach-Object { + if ($_.Line -gt 0) { "$($_.File):$($_.Line) $($_.Message)" } else { "$($_.File) $($_.Message)" } + } + Write-Error ("Public source policy failed:`n - " + ($lines -join "`n - ")) + exit 1 +} + +if ($advisory.Count -gt 0) { + Write-Host "Public source policy passed with $($advisory.Count) advisory comment finding(s)." +} +else { + Write-Host 'Public source policy passed.' +} diff --git a/src/Halo.App/Agents/Moods.cs b/src/Halo.App/Agents/Moods.cs new file mode 100644 index 0000000..52eed07 --- /dev/null +++ b/src/Halo.App/Agents/Moods.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; + +namespace Halo.Agents; + +internal readonly record struct MoodContext( + TimeSpan? Running = null, + float ContextFrac = 0f, + float UsageFrac = 0f, + long PromptTokens = 0, + int ToolRuns = 0, + int? Hour = null, + + string? Target = null, + + int MaxChars = 0); + +internal static class Moods +{ + internal const int MaxWidth = 22; + + private static readonly TimeSpan LongAfter = TimeSpan.FromMinutes(2); + private static readonly TimeSpan AgesAfter = TimeSpan.FromMinutes(8); + private const string LongSuffix = "@long"; + private const string AgesSuffix = "@ages"; + private const string TightSuffix = "@tight"; + private const string ThinSuffix = "@thin"; + private const string AgainSuffix = "@again"; + private const string HeavySuffix = "@heavy"; + private const string LateSuffix = "@late"; + private const string EarlySuffix = "@early"; + + internal const float TightAt = 0.80f; + internal const float ThinAt = 0.90f; + internal const int AgainAfter = 4; + internal const long HeavyTokens = 60_000; + + private static readonly Dictionary Pool = new(StringComparer.Ordinal) + { + ["idle"] = new[] + { + "let's work :)", "standing by", "all yours", "nothing on", "clear desk", + + "say the word", "on standby", "queue's empty", "awaiting orders", "ready", + "put me to work", "unoccupied", "primed", "twiddling thumbs", "at your service", + "tools down", "bench is clear", "apron on", "gloves on", + }, + ["offline"] = new[] + { + "offline :(", "no signal", "off the grid", "unplugged", "link down", "no connection", + "no route out", "cut off", "adrift", "stranded", "disconnected", "net's gone", + }, + ["apiDown"] = new[] + { + "api down :(", "api's out", "api unreachable", "no answer", "upstream silent", + "api asleep", "api's away", "upstream dark", "no reply", + }, + ["netError"] = new[] + { + "net error :(", "line broke", "dropped", "connection lost", "net gave out", + "it hung up", "line went dead", "pipe broke", "signal cut", + }, + ["apiError"] = new[] + { + "api error :(", "api said no", "bad reply", "refused", "error back", "api objects", + "a firm no", "rejected", + }, + ["compacted"] = new[] + { + "compacted :)", "room again", "made space", "trimmed", "breathing room", + "lighter now", "roomier", "slimmer now", "fresh headroom", "bench wiped down", + "tidy again", + }, + ["outOfCredit"] = new[] + { + "outta juice XD", "tank's empty", "credit spent", "budget's gone", "out of runway", + "spent up", "dry till reset", "all used up", "meter's at zero", "gauge on empty", + }, + + ["writing"] = new[] + { + "writing…", "editing…", "typing…", "drafting…", "on the keys…", "writing code…", + "composing…", "authoring…", "making changes…", "putting it down…", "shaping it…", + "laying bricks…", "mixing cement…", "measuring twice…", "cutting to fit…", + }, + ["reading"] = new[] + { + "reading…", "skimming…", "having a look…", "eyes on it…", "studying…", "parsing it…", + "reading up…", "absorbing…", "scanning…", "poring over it…", + "reading the manual…", "eyeing the wiring…", "analyzing…", + }, + ["running"] = new[] + { + "running…", "executing…", "in flight…", "crunching…", "under way…", "churning…", + "processing…", "off it goes…", "shell's busy…", "in progress…", + "on the hob…", "in the oven…", "cranking it…", "working…", + }, + ["digging"] = new[] + { + "digging…", "rummaging…", "spelunking…", "sifting…", "prospecting…", "foraging…", + "indexing…", + "poking around…", "on the trail…", "combing code…", "raking through…", + "torch and gloves…", "under the floor…", "behind the panel…", "hood's up…", + "hmm, where…", "it's in here…", + }, + ["fetching"] = new[] + { + "fetching…", "downloading…", "grabbing it…", "retrieving…", "collecting…", + "in transit…", "on the wire…", "reeling it in…", "pulling it down…", + "van's on the way…", "waiting on parts…", + }, + ["searching"] = new[] + { + "googling :P", "searching…", "looking it up…", "trawling…", "web hunting…", + "asking the web…", "browsing…", "querying…", + "asking the forum…", "thumbing the index…", + }, + ["delegating"] = new[] + { + "delegating…", "handing off…", "passing it on…", "calling backup…", "deputising…", + "sending help…", "farming it out…", "sharing the load…", + "calling a plumber…", "an apprentice goes…", + }, + ["planning"] = new[] + { + "planning…", "sketching…", "outlining…", "mapping it…", "scoping it…", + "drawing it up…", "lining it up…", "thinking ahead…", + "envelope maths…", "chalk on the wall…", "tape measure out…", + }, + ["skill"] = new[] + { + "using a skill…", "loading a skill…", "by the book…", "on the playbook…", + "the recipe…", "following steps…", "mise en place…", "the manual says…", + }, + ["asking"] = new[] + { + "asking you :)", "your turn", "your move", "over to you", "needs a call", + "a question", "wants a word", "your say-so", "needs a hand", "hold this?", + }, + + ["unknown"] = new[] + { + "hmm…", "thinking…", "considering…", "mulling it…", "chewing on it…", + "figuring it out…", "reasoning…", "weighing it up…", "deliberating…", "sizing it up…", + "having a think…", "turning it over…", + "measuring up…", "eyeing it up…", "head-scratching…", + "hmm, ok…", "erm…", "uhh…", "let's see…", "right then…", "so…", + + "reflecting…", "synthesizing…", "undulating…", + }, + ["compacting"] = new[] + { + "compacting…", "condensing…", "packing up…", "making room…", "trimming…", + "boiling it down…", "squeezing…", "clearing the bench…", "reducing it…", + }, + ["patching"] = new[] + { + "patching…", "applying a fix…", "mending…", "amending…", "stitching…", + "splicing it in…", "touching it up…", + "duct tape…", "wd-40 moment…", "bit of filler…", "sealing the leak…", + }, + ["plotting"] = new[] + { + "plotting…", "replanning…", "revising…", "reordering…", "re-scoping…", + "shuffling tasks…", "redrawing it…", "new blueprint…", + }, + + ["watching"] = new[] + { + "watching…", "keeping an eye…", "on the dial…", "waiting on it…", + "watching the pot…", "tailing it…", + }, + ["reviewing"] = new[] + { + "reviewing…", "checking the work…", "inspecting…", "snagging…", "second look…", + "going over it…", "analyzing…", + }, + ["publishing"] = new[] + { + "publishing…", "shipping it…", "out the door…", "posting it…", "handing it over…", + }, + ["consulting"] = new[] + { + "consulting…", "asking a tool…", "asking next door…", "phoning a friend…", + "calling the desk…", "connecting…", + }, + ["peeking"] = new[] + { + "peeking o.o", "having a peek…", "eyes on the shot…", "taking a look…", + }, + + ["writing" + LongSuffix] = new[] + { + "still writing…", "long file…", "still typing…", "quite the essay…", "chapter two…", + "still laying bricks…", + }, + ["reading" + LongSuffix] = new[] + { + "still reading…", "long read…", "deep in it…", "still going…", "engrossed…", + }, + ["running" + LongSuffix] = new[] + { + "still running…", "long job…", "still churning…", "taking its time…", "any minute…", + "still on the hob…", + }, + ["digging" + LongSuffix] = new[] + { + "still digging…", "big haystack…", "deep in it…", "still hunting…", "big tree…", + "still under there…", + }, + ["fetching" + LongSuffix] = new[] + { + "still fetching…", "slow pipe…", "trickling in…", "still coming…", "byte by byte…", + }, + ["searching" + LongSuffix] = new[] + { + "still searching…", "still looking…", "page four…", "web is coy…", "hard to find…", + }, + ["delegating" + LongSuffix] = new[] + { + "helper's busy…", "still out…", "no word back…", "sub is thinking…", + }, + ["planning" + LongSuffix] = new[] + { + "still planning…", "big plan…", "still sketching…", "many boxes…", + }, + ["skill" + LongSuffix] = new[] + { + "long recipe…", "still on it…", "many steps…", + }, + ["unknown" + LongSuffix] = new[] + { + "still thinking…", "deep thought…", "long think…", "cogitating…", "one minute…", + "hmmm…", "hmm, tricky…", "erm, hang on…", + }, + ["compacting" + LongSuffix] = new[] + { + "still compacting…", "lots to fold…", "big history…", "still squeezing…", + }, + ["patching" + LongSuffix] = new[] + { + "still patching…", "fiddly patch…", "still stitching…", "careful now…", "more filler…", + }, + ["plotting" + LongSuffix] = new[] + { + "still plotting…", "big list…", "still shuffling…", "many tasks…", + }, + + ["writing" + AgesSuffix] = new[] + { + "war and peace…", "some novel…", "hope it's good…", "a whole wall of it…", + }, + ["reading" + AgesSuffix] = new[] + { + "a long book…", "every last line…", + }, + ["running" + AgesSuffix] = new[] + { + "long haul…", "make a coffee…", "settle in…", "kettle's on…", "low and slow…", + "still simmering…", + }, + ["digging" + AgesSuffix] = new[] + { + "needle, meet hay…", "deep down there…", "floorboards are up…", + }, + ["fetching" + AgesSuffix] = new[] + { + "dial-up speeds…", "glacial…", + }, + ["searching" + AgesSuffix] = new[] + { + "web's hiding it…", "page ten…", + }, + ["delegating" + AgesSuffix] = new[] + { + "still out there…", "no word yet…", + }, + ["planning" + AgesSuffix] = new[] + { + "grand strategy…", "epic scope…", + }, + ["skill" + AgesSuffix] = new[] + { + "a long recipe…", "still in it…", + }, + ["unknown" + AgesSuffix] = new[] + { + "deep in thought…", "still cooking…", "hard problem…", + "hmmmm…", "well, hmm…", "still erm-ing…", + }, + ["compacting" + AgesSuffix] = new[] + { + "huge history…", "still packing…", + }, + ["patching" + AgesSuffix] = new[] + { + "stubborn patch…", "still fiddling…", "more tape…", + }, + ["plotting" + AgesSuffix] = new[] + { + "epic list…", "grand agenda…", + }, + + ["idle" + TightSuffix] = new[] + { + "worth a /compact", "desk needs clearing", "no room left", + }, + ["unknown" + TightSuffix] = new[] + { + "no room to think…", "desk is buried…", "bench is covered…", "hmm, no room…", + }, + ["running" + TightSuffix] = new[] + { + "no room to work…", "elbows in…", + }, + ["writing" + TightSuffix] = new[] + { + "margins are gone…", "writing in the gaps…", + }, + ["reading" + TightSuffix] = new[] + { + "no shelf left…", "nowhere to file it…", + }, + ["digging" + TightSuffix] = new[] + { + "nowhere to put it…", "bench is full…", + }, + + ["idle" + ThinSuffix] = new[] + { + "nearly out", "last drops", "running low", + }, + ["unknown" + ThinSuffix] = new[] + { + "rationing it…", "last of the tank…", + }, + ["running" + ThinSuffix] = new[] + { + "coasting…", "on fumes…", + }, + ["writing" + ThinSuffix] = new[] + { + "short strokes…", "sparing the ink…", + }, + + ["unknown" + AgainSuffix] = new[] + { + "same drill…", "on repeat…", "hmm, again…", + }, + ["running" + AgainSuffix] = new[] + { + "again…", "one more pass…", "round after round…", + }, + ["digging" + AgainSuffix] = new[] + { + "another cupboard…", "next drawer…", + }, + ["writing" + AgainSuffix] = new[] + { + "another draft…", "again, with feeling…", + }, + ["patching" + AgainSuffix] = new[] + { + "another go at it…", "third coat…", + }, + + ["unknown" + HeavySuffix] = new[] + { + "hands full…", "a big order…", + }, + ["running" + HeavySuffix] = new[] + { + "heavy load…", "big job on…", + }, + ["reading" + HeavySuffix] = new[] + { + "a lot on the bench…", "the whole file…", + }, + ["writing" + HeavySuffix] = new[] + { + "a long shift…", "big pour…", + }, + + ["idle" + LateSuffix] = new[] + { + "still up?", "night shift", "burning oil", "quiet hours", + }, + ["unknown" + LateSuffix] = new[] + { + "small hours…", "night thoughts…", + }, + ["running" + LateSuffix] = new[] + { + "the night shift…", "graveyard shift…", + }, + ["writing" + LateSuffix] = new[] + { + "by lamplight…", "one more then bed…", + }, + ["idle" + EarlySuffix] = new[] + { + "kettle first", "morning", "coffee then work", + }, + ["unknown" + EarlySuffix] = new[] + { + "waking up…", "still yawning…", + }, + ["running" + EarlySuffix] = new[] + { + "early start…", "beating the rush…", + }, + }; + + internal static IEnumerable Keys => Pool.Keys; + + internal static string[] Set(string key) => Pool.TryGetValue(key, out var v) ? v : Array.Empty(); + + private static readonly Random Rng = new(); + private static readonly object Gate = new(); + + private static readonly Dictionary Held = new(StringComparer.Ordinal); + private static readonly TimeSpan Hold = TimeSpan.FromSeconds(60); + + internal static string Fixed(string slot) + { + var i = slot.IndexOf('@'); + if (i > 0) slot = slot.Substring(0, i); + var set = Set(slot); + return set.Length > 0 ? set[0] : "hmm…"; + } + + internal static string Line(string slot) => Line(slot, null); + + internal static string Line(string slot, TimeSpan? running) => Line(slot, new MoodContext(running)); + + private static readonly (string suffix, Func when)[] Ladder = + { + (TightSuffix, c => c.ContextFrac >= TightAt), + (ThinSuffix, c => c.UsageFrac >= ThinAt), + (AgesSuffix, c => c.Running >= AgesAfter), + (LongSuffix, c => c.Running >= LongAfter), + (AgainSuffix, c => c.ToolRuns >= AgainAfter), + (HeavySuffix, c => c.PromptTokens >= HeavyTokens), + (LateSuffix, c => c.Hour is >= 0 and <= 4), + (EarlySuffix, c => c.Hour is >= 5 and <= 7), + }; + + internal static string Modifier(in MoodContext ctx) + { + foreach (var (suffix, when) in Ladder) if (when(ctx)) return suffix; + return ""; + } + + internal static string Line(string slot, in MoodContext ctx) => Line(slot, ctx, DateTime.UtcNow); + + internal static string Line(string slot, in MoodContext ctx, DateTime now) + { + var key = slot; + foreach (var (suffix, when) in Ladder) + { + if (!when(ctx)) continue; + var candidate = slot + suffix; + if (!Pool.ContainsKey(candidate)) continue; + key = candidate; + break; + } + + if (key == slot && Fact(slot, ctx.Target, ctx.MaxChars) is { } f) return f; + string? stale = null; + lock (Gate) + { + if (Held.TryGetValue(key, out var h)) + { + + if (now >= h.at && now - h.at < Hold && (ctx.MaxChars <= 0 || h.line.Length <= ctx.MaxChars)) + return h.line; + stale = h.line; + } + } + var picked = Pick(key, stale, ctx.MaxChars); + lock (Gate) Held[key] = (picked, now); + return picked; + } + + internal static string? Fact(string? slot, string? target, int maxChars = 0) + { + if (string.IsNullOrWhiteSpace(target)) return null; + var verb = slot switch + { + "writing" => "writing ", + "patching" => "patching ", + "reading" => "reading ", + "peeking" => "peeking at ", + "running" => "running ", + "digging" => "digging ", + "fetching" => "fetching ", + "searching" => "searching ", + "delegating" or "consulting" => "asking ", + "skill" => "", + _ => null, + }; + if (verb is null) return null; + var line = verb + target.Trim() + "…"; + int ceiling = maxChars > 0 ? Math.Min(maxChars, MaxWidth) : MaxWidth; + return line.Length <= ceiling ? line : null; + } + + internal static string PrettyTool(string? tool) + { + var t = (tool ?? "").Trim(); + if (t.Length == 0) return Fixed("unknown"); + if (t.StartsWith("mcp__", StringComparison.Ordinal)) + { + var parts = t.Split("__", StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) t = parts[1]; + } + t = t.Replace('_', ' ').Replace('-', ' ').Trim().ToLowerInvariant(); + if (t.Length == 0) return Fixed("unknown"); + if (t.Length > MaxWidth - 1) t = t.Substring(0, MaxWidth - 1).TrimEnd(); + return t + "…"; + } + + internal static string Pick(string key, string? avoid = null, int maxChars = 0) + { + var set = Set(key); + if (set.Length == 0) return Fixed(key); + + if (maxChars > 0) + { + var fits = Array.FindAll(set, s => s.Length <= maxChars); + if (fits.Length == 0) + { + var shortest = set[0]; + foreach (var s in set) if (s.Length < shortest.Length) shortest = s; + return shortest; + } + set = fits; + } + lock (Gate) + { + int i = Rng.Next(set.Length); + + if (avoid is not null && set.Length > 1 && set[i] == avoid) i = (i + 1) % set.Length; + return set[i]; + } + } +} diff --git a/src/Halo.App/Api/HaloApi.cs b/src/Halo.App/Api/HaloApi.cs new file mode 100644 index 0000000..5eb6c29 --- /dev/null +++ b/src/Halo.App/Api/HaloApi.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json.Nodes; + +namespace Halo.Api; + +internal sealed class HaloApi : IDisposable +{ + internal const int DefaultPort = 7317; + + private readonly Func _config; + private readonly IHaloHost _host; + private TcpListener? _listener; + private int _port; + private volatile bool _stop; + + internal sealed record Config( + bool Enabled, int Port, string Token, + bool Notify, bool Ask, bool State, bool Control, bool Settings); + + internal HaloApi(Func config, IHaloHost host) + { + _config = config; + _host = host; + } + + internal string? LastError { get; private set; } + + internal void Reconcile() + { + try + { + var config = _config(); + if (!config.Enabled || config.Token.Length == 0) { Stop(); return; } + if (_listener != null && _port == config.Port) return; + Stop(); + Start(config.Port); + } + catch (Exception e) { LastError = e.Message; } + } + + private void Start(int port) + { + try + { + _stop = false; + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + _port = port; + LastError = null; + var listener = _listener; + var thread = new System.Threading.Thread(() => Accept(listener)) { IsBackground = true }; + thread.Start(); + } + catch (Exception e) + { + LastError = e.Message; + _listener = null; + } + } + + private void Stop() + { + _stop = true; + try { _listener?.Stop(); } catch { } + _listener = null; + _port = 0; + } + + private void Accept(TcpListener listener) + { + while (!_stop) + { + TcpClient client; + try { client = listener.AcceptTcpClient(); } + catch { return; } + System.Threading.ThreadPool.QueueUserWorkItem(_ => Serve(client)); + } + } + + private void Serve(TcpClient client) + { + using (client) + { + try + { + client.ReceiveTimeout = 5000; + client.SendTimeout = 5000; + using var stream = client.GetStream(); + var request = Request.Read(stream); + if (request is null) return; + var (status, body) = Route(request); + Write(stream, status, body); + } + catch { } + } + } + + private (int Status, JsonObject Body) Route(Request request) + { + var config = _config(); + if (!Constant(request.Token, config.Token)) + return (401, Error("bad or missing token")); + + string path = request.Path.TrimEnd('/'); + if (path.Length == 0) path = "/"; + + if (request.Method == "GET" && path == "/health") + return (200, new JsonObject + { + ["ok"] = true, + ["product"] = "Halo", + ["capabilities"] = new JsonArray( + config.Notify ? "notify" : null, config.Ask ? "ask" : null, + config.State ? "state" : null, config.Control ? "control" : null, + config.Settings ? "settings" : null), + }); + + return (request.Method, path) switch + { + ("POST", "/notify") => config.Notify ? Notify(request) : Off(), + ("POST", "/ask") => config.Ask ? Ask(request) : Off(), + ("GET", _) when path.StartsWith("/ask/", StringComparison.Ordinal) + => config.Ask ? Answer(path[5..]) : Off(), + + ("GET", "/state") => config.State ? (200, _host.State()) : Off(), + ("GET", "/media") => config.State ? (200, _host.Media()) : Off(), + ("GET", "/agents") => config.State ? (200, _host.Agents()) : Off(), + ("GET", "/tray") => config.State ? (200, _host.Tray()) : Off(), + + ("POST", "/media") => config.Control ? MediaControl(request) : Off(), + ("POST", "/pill") => config.Control ? Pill(request) : Off(), + ("POST", "/tray") => config.Control ? TrayAdd(request) : Off(), + + ("GET", "/settings") => config.Settings ? (200, _host.Settings()) : Off(), + ("PATCH", "/settings") => config.Settings ? Patch(request) : Off(), + + _ => (404, Error("no such endpoint")), + }; + } + + private static (int, JsonObject) Off() + => (403, Error("that capability is switched off in Halo's settings")); + + private (int, JsonObject) MediaControl(Request request) + { + string action = Str(request.Json, "action"); + if (action.Length == 0) return (400, Error("action is required")); + int slot = Int(request.Json, "slot", -1); + bool sent = _host.MediaControl(action, slot); + return sent + ? (200, new JsonObject { ["ok"] = true }) + : (400, Error("no session to control, or unknown action")); + } + + private (int, JsonObject) Pill(Request request) + { + string action = Str(request.Json, "action"); + if (action.Length == 0) return (400, Error("action is required")); + return _host.Pill(action) + ? (200, new JsonObject { ["ok"] = true }) + : (400, Error("unknown action")); + } + + private (int, JsonObject) TrayAdd(Request request) + { + var paths = new List(); + if (request.Json?["paths"] is JsonArray array) + foreach (var node in array) + if (node?.GetValue() is { Length: > 0 } p) paths.Add(p); + else if (Str(request.Json, "path") is { Length: > 0 } single) paths.Add(single); + if (paths.Count == 0) return (400, Error("paths is required")); + + int added = _host.TrayAdd(paths); + + return (200, new JsonObject { ["added"] = added, ["skipped"] = paths.Count - added }); + } + + private (int, JsonObject) Patch(Request request) + { + if (request.Json?["values"] is not JsonObject values || values.Count == 0) + return (400, Error("values is required")); + int written = _host.SettingsPatch(values); + return (200, new JsonObject { ["written"] = written }); + } + + private (int, JsonObject) Notify(Request request) + { + var json = request.Json; + string title = Str(json, "title"); + if (title.Length == 0) return (400, Error("title is required")); + _host.Notify(new NotifyRequest( + Str(json, "app", "Halo"), title, Str(json, "body"), + Int(json, "seconds", 6), Str(json, "code"), Str(json, "launch"))); + return (200, new JsonObject { ["ok"] = true }); + } + + private (int, JsonObject) Ask(Request request) + { + var json = request.Json; + string question = Str(json, "question"); + if (question.Length == 0) return (400, Error("question is required")); + + var options = new JsonArray(); + if (json?["options"] is JsonArray given) + foreach (var node in given) + { + string label = node is JsonObject o ? Str(o, "label") : node?.GetValue() ?? ""; + if (label.Length == 0) continue; + options.Add(new JsonObject + { + ["label"] = label, + ["description"] = node is JsonObject d ? Str(d, "description") : "", + }); + } + if (options.Count == 0) return (400, Error("at least one option is required")); + + string nonce = Guid.NewGuid().ToString("n"); + int seconds = Math.Clamp(Int(json, "timeoutSeconds", 300), 10, 3600); + var envelope = new JsonObject + { + ["nonce"] = nonce, + ["pid"] = 0, + ["session"] = Str(json, "app", "api"), + ["tool"] = "HaloApi", + ["target"] = Str(json, "app", "API"), + ["question"] = question, + ["options"] = options, + ["expiresAt"] = DateTimeOffset.UtcNow.AddSeconds(seconds).ToString("o"), + }; + try + { + Directory.CreateDirectory(AskDir); + string path = Path.Combine(AskDir, $"ask-{nonce}.json"); + File.WriteAllText(path + ".tmp", envelope.ToJsonString()); + File.Move(path + ".tmp", path, overwrite: true); + } + catch (Exception e) { return (500, Error(e.Message)); } + + return (200, new JsonObject { ["nonce"] = nonce, ["poll"] = $"/ask/{nonce}" }); + } + + private (int, JsonObject) Answer(string nonce) + { + if (nonce.Length == 0 || nonce.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + return (400, Error("bad nonce")); + try + { + string answer = Path.Combine(AskDir, $"answer-{nonce}.json"); + if (File.Exists(answer)) + { + var parsed = JsonNode.Parse(File.ReadAllText(answer)) as JsonObject; + try { File.Delete(answer); } catch { } + try { File.Delete(Path.Combine(AskDir, $"ask-{nonce}.json")); } catch { } + return (200, new JsonObject { ["answered"] = true, ["choice"] = Str(parsed, "decision") }); + } + bool live = File.Exists(Path.Combine(AskDir, $"ask-{nonce}.json")); + return (200, new JsonObject { ["answered"] = false, ["pending"] = live }); + } + catch (Exception e) { return (500, Error(e.Message)); } + } + + private static string AskDir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "notch"); + + private static bool Constant(string a, string b) + { + if (a.Length != b.Length || b.Length == 0) return false; + int diff = 0; + for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + + private static JsonObject Error(string message) => new() { ["error"] = message }; + + private static string Str(JsonObject? o, string key, string fallback = "") + => o?[key] is JsonValue v && v.TryGetValue(out var s) && s.Length > 0 ? s : fallback; + + private static int Int(JsonObject? o, string key, int fallback) + => o?[key] is JsonValue v && v.TryGetValue(out var i) ? i : fallback; + + private static void Write(Stream stream, int status, JsonObject body) + { + byte[] payload = Encoding.UTF8.GetBytes(body.ToJsonString()); + var head = new StringBuilder() + .Append("HTTP/1.1 ").Append(status).Append(' ').Append(Reason(status)).Append("\r\n") + .Append("Content-Type: application/json; charset=utf-8\r\n") + .Append("Content-Length: ").Append(payload.Length).Append("\r\n") + .Append("Connection: close\r\n\r\n") + .ToString(); + byte[] header = Encoding.ASCII.GetBytes(head); + stream.Write(header, 0, header.Length); + stream.Write(payload, 0, payload.Length); + stream.Flush(); + } + + private static string Reason(int status) => status switch + { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + _ => "Internal Server Error", + }; + + private sealed record Request(string Method, string Path, string Token, JsonObject? Json) + { + private const int MaxBody = 64 * 1024; + + internal static Request? Read(Stream stream) + { + var head = new StringBuilder(); + var one = new byte[1]; + + while (!head.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { + if (stream.Read(one, 0, 1) != 1) return null; + head.Append((char)one[0]); + if (head.Length > 8192) return null; + } + + var lines = head.ToString().Split("\r\n"); + var start = lines[0].Split(' '); + if (start.Length < 2) return null; + + string token = ""; + int length = 0; + foreach (var line in lines) + { + int colon = line.IndexOf(':'); + if (colon <= 0) continue; + string name = line[..colon].Trim(); + string value = line[(colon + 1)..].Trim(); + if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + token = value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? value[7..] : value; + else if (name.Equals("X-Halo-Token", StringComparison.OrdinalIgnoreCase)) token = value; + else if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + int.TryParse(value, out length); + } + + JsonObject? json = null; + if (length is > 0 and <= MaxBody) + { + var body = new byte[length]; + int read = 0; + while (read < length) + { + int n = stream.Read(body, read, length - read); + if (n <= 0) break; + read += n; + } + try { json = JsonNode.Parse(Encoding.UTF8.GetString(body, 0, read)) as JsonObject; } + catch { return null; } + } + + string path = start[1]; + int query = path.IndexOf('?'); + if (query >= 0) path = path[..query]; + return new Request(start[0].ToUpperInvariant(), path, token, json); + } + } + + public void Dispose() => Stop(); +} diff --git a/src/Halo.App/Api/IHaloHost.cs b/src/Halo.App/Api/IHaloHost.cs new file mode 100644 index 0000000..fb6688d --- /dev/null +++ b/src/Halo.App/Api/IHaloHost.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Halo.Api; + +internal interface IHaloHost +{ + JsonObject State(); + JsonObject Media(); + JsonObject Agents(); + JsonObject Tray(); + JsonObject Settings(); + + void Notify(NotifyRequest request); + bool MediaControl(string action, int slot); + bool Pill(string action); + int TrayAdd(IReadOnlyList paths); + int SettingsPatch(JsonObject values); + + bool Post(System.Action work); +} + +internal sealed record NotifyRequest( + string App, string Title, string Body, double Seconds, string Code, string LaunchPath); diff --git a/src/Halo.App/Assets/claude.png b/src/Halo.App/Assets/claude.png new file mode 100644 index 0000000..5057173 Binary files /dev/null and b/src/Halo.App/Assets/claude.png differ diff --git a/src/Halo.App/Assets/halo.ico b/src/Halo.App/Assets/halo.ico new file mode 100644 index 0000000..c113fac Binary files /dev/null and b/src/Halo.App/Assets/halo.ico differ diff --git a/src/Halo.App/Assets/openai.png b/src/Halo.App/Assets/openai.png new file mode 100644 index 0000000..1c3fb74 Binary files /dev/null and b/src/Halo.App/Assets/openai.png differ diff --git a/src/Halo.App/Assets/uia-cancel.ps1 b/src/Halo.App/Assets/uia-cancel.ps1 new file mode 100644 index 0000000..fb07191 --- /dev/null +++ b/src/Halo.App/Assets/uia-cancel.ps1 @@ -0,0 +1,338 @@ +# Press Cancel on a browser download, from outside the browser. +# +# Runs under Windows PowerShell 5.1 rather than in-process, and that is the point. Chrome is UIA-first +# (MSAA returns zero children on the frame window AND on every Chrome_RenderWidgetHostHWND), so reaching +# the control means an IUIAutomation client. Hand-writing that COM vtable is ~400 lines where one wrong +# slot is an access violation, and this repo's first rule is that nothing may crash the pill. 5.1 ships +# UIAutomationClient in the GAC on every Windows install, so the whole client costs a process boundary +# instead — and a hang or crash out here cannot touch the notch. +# +# Ctrl+J is sent from HERE, not by the caller, because both browsers now answer it with a flyout that +# closes itself. Measured on Chrome: the caller sent Ctrl+J, spawned this process, and by the time the +# tree could be read the bubble was gone and the sweep saw the New Tab page. Opening the list and acting +# on it has to happen inside one uninterrupted run, with focus never leaving the browser. +# +# Exit codes are the contract: 0 pressed, 2 nothing to press, 3 no way to open the list. +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +$hwnd = [IntPtr]__HWND__ +$target = '__TARGET__' +$canTab = '__CANTAB__' -eq '1' # is the browser confirmed in front? only then may we send keystrokes + +# Neither control carries a stable AutomationId, so this matches on name; an unmatched locale falls out +# as exit 2 and the caller still leaves the downloads list open in front of the user. +$cancelLabels = @('Cancel', 'Cancel download', 'Abbrechen', 'Annuler', 'Cancelar', 'Annulla', + 'Anuluj', 'Avbryt', 'Annuleren', 'Iptal') +# 'More options' is deliberately NOT here. Edge's downloads flyout has a control by that name, but it is +# the flyout's own overflow, not a row menu — and the single-candidate fallback below was pressing it, +# opening download settings and then reporting that no cancel item existed. +$moreLabels = @('More actions', 'Weitere Aktionen', 'Plus d''actions', 'Mas acciones', 'Altre azioni') + +# Which browser this is decides whether the focus walk below is worth running at all, and the answer is +# already written in the comments further down: Chrome's downloads bubble exposes ONE control per row, so +# tabbing through it can never land on a Cancel — only the downloads PAGE has one. Edge is the opposite and +# needs the walk. Running it for Chrome anyway is what the user sees as the cursor marching down the list +# one row at a time for several seconds before anything happens. +$proc = '' +try { $proc = (Get-Process -Id ([System.Windows.Automation.AutomationElement]::FromHandle($hwnd)).Current.ProcessId).ProcessName.ToLower() } catch { } +$bubbleOnly = @('chrome', 'brave', 'vivaldi', 'opera', 'opera_gx') -contains $proc + +if (-not ('Halo.Cursor' -as [type])) { + Add-Type -Namespace Halo -Name Cursor -MemberDefinition @' +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct PT { public int X; public int Y; } +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool GetCursorPos(out PT p); +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern short GetAsyncKeyState(int vk); +'@ +} +function CursorAt { + $p = New-Object Halo.Cursor+PT + [void][Halo.Cursor]::GetCursorPos([ref]$p) + return $p +} + +$root = [System.Windows.Automation.AutomationElement]::FromHandle($hwnd) +$isControl = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::IsControlElementProperty, $true) +$Desc = [System.Windows.Automation.TreeScope]::Descendants + +# Chrome puts its downloads on a PAGE inside the frame window; Edge opens a FLYOUT, which is its own +# top-level window. Searching only the frame's subtree therefore found Edge's menu button but never the +# Cancel item inside the flyout, and reported "menu opened but no cancel item". Sweep every top-level +# window the browser process owns instead of just the one we were handed. +$ownerPid = $root.Current.ProcessId +$byPid = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $ownerPid) + +function Sweep { + $out = New-Object System.Collections.ArrayList + foreach ($top in [System.Windows.Automation.AutomationElement]::RootElement.FindAll( + [System.Windows.Automation.TreeScope]::Children, $byPid)) { + try { foreach ($e in $top.FindAll($Desc, $isControl)) { [void]$out.Add($e) } } catch { } + } + if ($out.Count -eq 0) { foreach ($e in $root.FindAll($Desc, $isControl)) { [void]$out.Add($e) } } + return $out +} + +function Named($all, $labels) { + $r = @() + foreach ($e in $all) { if ($labels -contains $e.Current.Name) { $r += $e } } + return $r +} + +# Advertising a pattern is not the same as honouring it: Edge's downloads toolbar button reports +# ExpandCollapse and answers Expand with E_FAIL. With $ErrorActionPreference = 'Stop' that killed the whole +# script on its first strategy and reported rc=1, so a cancel that would have worked one strategy later +# never got there. Each attempt is therefore its own try, and a refusal just means "try the next thing". +function Press($e) { + $p = @() + try { $p = $e.GetSupportedPatterns() } catch { return $false } + if ($p -contains [System.Windows.Automation.InvokePattern]::Pattern) { + try { $e.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke(); return $true } catch { } + } + # a menu BUTTON expands rather than invokes; the menu ITEM inside it is the one that invokes + if ($p -contains [System.Windows.Automation.ExpandCollapsePattern]::Pattern) { + try { $e.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern).Expand(); return $true } catch { } + } + # Chrome's toolbar downloads button carries ONLY TogglePattern. Leaving it out here meant the one + # control that opens Chrome's download bubble was reported as unpressable and silently skipped. + if ($p -contains [System.Windows.Automation.TogglePattern]::Pattern) { + try { $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Toggle(); return $true } catch { } + } + return $false +} + +# The toolbar's downloads button, which is what opens the list. Matched by shape rather than by an exact +# label: it is the one download-ish control that opens something, and the pattern requirement is what +# separates it from its neighbours ("Open downloads folder" only invokes; the flyout's own "Downloads" +# heading only invokes). Edge names it "Downloads, 5% complete" with ExpandCollapse; Chrome names it +# "1 download in progress" with Toggle. +function DownloadsButton($all) { + foreach ($e in $all) { + try { + $n = $e.Current.Name + if (-not $n -or $n -notmatch '(?i)download') { continue } + if ($e.Current.ControlType.ProgrammaticName -notlike '*Button*') { continue } + $p = $e.GetSupportedPatterns() + if (($p -contains [System.Windows.Automation.TogglePattern]::Pattern) -or + ($p -contains [System.Windows.Automation.ExpandCollapsePattern]::Pattern)) { return $e } + } catch { } + } + return $null +} + +if (-not ('Halo.Keys' -as [type])) { + Add-Type -Namespace Halo -Name Keys -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern void keybd_event(byte vk, byte scan, uint flags, System.IntPtr extra); +'@ +} +function Tap([byte]$vk) { + [Halo.Keys]::keybd_event($vk, 0, 0, [IntPtr]::Zero) + [Halo.Keys]::keybd_event($vk, 0, 2, [IntPtr]::Zero) +} + +# ── open the list ────────────────────────────────────────────────────────────────────────────────────── +# +# Pressing the toolbar's own downloads button beats sending Ctrl+J, and not by a little: a keystroke goes +# to whatever holds the foreground, so it needs the browser in front, and Windows grants foreground rights +# only to the process receiving input — which the pill does not always still have by the time this runs. +# Measured: with rights denied, the old path sent nothing, found nothing and left the user staring at an +# unchanged download. UIA presses the button whether or not the browser is in front. +# +# Chrome needs this for a second reason. Its Ctrl+J bubble never appears in the accessibility tree at all +# (measured: after Ctrl+J the only download-ish control anywhere was the toolbar button itself), while +# toggling that button puts a "Recent download history" window in the tree with the row inside it. +# The retry is not optional: a browser builds its accessibility tree only once a UIA client attaches, and +# attaching is what this script just did. The first sweep can come back with a handful of controls and no +# toolbar at all — measured here as "no downloads button" against an Edge window that plainly had one. +# +# For a $bubbleOnly browser none of this applies: the bubble it would open has no Cancel in it, so opening +# one costs a second and leaves a popup sitting over the user's screen that still has to be stepped around. +# The tree does have to be built before anything can be found, though, and attaching a UIA client is what +# builds it — so the sweep still runs, it just does not press. +$opened = $false +for ($try = 0; $try -lt 5; $try++) { + $btn = DownloadsButton (Sweep) + if ($btn) { if (-not $bubbleOnly) { $opened = Press $btn }; break } + Start-Sleep -Milliseconds 400 +} +if ($opened) { Start-Sleep -Milliseconds 900 } +elseif ($canTab -and -not $bubbleOnly) { + # Ctrl+J is the one shortcut every Chromium browser and Firefox share — the fallback when the button + # could not be named. Only when the caller confirmed the browser is in front, or the keystroke lands + # in whatever window does hold it. + [Halo.Keys]::keybd_event(0x11, 0, 0, [IntPtr]::Zero) # Ctrl down + Tap 0x4A # J + [Halo.Keys]::keybd_event(0x11, 0, 2, [IntPtr]::Zero) # Ctrl up + Start-Sleep -Milliseconds 700 +} +# and if neither worked, carry on anyway: the list may already be open, and the app-menu strategy below +# needs no keystroke and no foreground rights. Giving up here is what made a click on Cancel do nothing. + +# ── strategy 1: walk keyboard focus ──────────────────────────────────────────────────────────────────── +# +# This is first because it is the only one that works against a list that closes itself, and because the +# controls it needs may not be in the tree at all until focus reaches them. Measured on Edge: a descendant +# search over the downloads flyout returns the row as a Group with an Image, two Texts and a ProgressBar — +# and no buttons whatsoever. Tab into that row and 'Pause' and 'Cancel' appear as the next two focus stops, +# with the row's Group name spelling out everything it contains ("File icon report.pdf 24.0 KB/s - 7.5 MB +# of 60.0 MB, 37 mins left 12 Pause Cancel"). That is why cancel worked in Chrome and did nothing at all +# in Edge for so long: the control was never there to be found. +# +# Three things now stop this walk, all of them the same bug seen from different sides: it is a long series +# of blind keystrokes, and a keystroke only means what you intended while nothing else has moved. +# * $bubbleOnly - Chrome has no Cancel to walk to. Skipped outright, which is also what makes cancelling +# a download in a LONG list instant instead of one Tab per row. +# * focus left - if the focused element stops belonging to the browser, the Tabs are landing in someone +# else's window. Measured as the user clicking anything mid-walk. +# * cursor moved - hovering a Chromium row moves focus on its own, so a walk that assumed it knew where +# it was is now several rows off and about to press Cancel on the wrong download. +# On any of them the walk just stops; the page strategy below needs neither focus nor foreground and is +# where Chrome was always going to end up anyway. +$rowName = '' +$first = '' +$startCursor = CursorAt +for ($step = 0; $canTab -and -not $bubbleOnly -and $step -lt 60; $step++) { + $f = $null + try { $f = [System.Windows.Automation.AutomationElement]::FocusedElement } catch { } + + $owned = $false + try { $owned = $f -and $f.Current.ProcessId -eq $ownerPid } catch { } + if (-not $owned) { Write-Output 'focus left the browser mid-walk; falling through to the page'; break } + + $c = CursorAt + if ([Math]::Abs($c.X - $startCursor.X) -gt 8 -or [Math]::Abs($c.Y - $startCursor.Y) -gt 8 -or + ([Halo.Cursor]::GetAsyncKeyState(0x01) -band 0x8000) -ne 0) { + Write-Output 'user moved the pointer mid-walk; falling through to the page' + break + } + + if ($f) { + $name = ''; $type = '' + try { $name = $f.Current.Name; $type = $f.Current.ControlType.ProgrammaticName } catch { } + $stop = "$type|$name" + if ($step -eq 0) { $first = $stop } + elseif ($stop -eq $first) { break } # wrapped all the way round: nothing to cancel here + + # remember the row we are inside, so a Cancel belongs to a known download and not to whichever + # row happened to come first + if ($name -and ($type -like '*Group*' -or $type -like '*ListItem*')) { $rowName = $name } + + if ($cancelLabels -contains $name) { + if (-not $target -or $rowName -like "*$target*" -or -not $rowName) { + if (Press $f) { Write-Output "pressed cancel via focus walk (row '$rowName')"; exit 0 } + } + } + } + Tap 0x09 # Tab + Start-Sleep -Milliseconds 150 +} + +# ── strategy 2: open the downloads PAGE through the app menu ─────────────────────────────────────────── +# +# Chrome's Ctrl+J and its toolbar button both produce a bubble whose rows expose exactly one control — the +# row itself — so there is nothing named Cancel to press anywhere in it (measured: 'Close' plus one Button +# per row, nothing else). The app menu's Downloads item opens the real chrome://downloads page instead, +# where each row carries 'Copy download link' and a 'More actions' menu holding Pause and Cancel. So the +# way into Chrome is its menu bar, not its download UI. +# +# The menu item is matched on 'Ctrl+J' rather than on the word "Downloads": the accelerator is the same +# string in every locale, and the label is not. +function OpenDownloadsPage { + $menuLabels = @('Chrome', 'Google Chrome', 'Customize and control Google Chrome', + 'Brave', 'Vivaldi', 'Opera', 'Firefox') + foreach ($e in (Sweep)) { + $ec = $null + try { + $n = $e.Current.Name + if (-not $n) { continue } + if (($menuLabels -notcontains $n) -and ($n -notlike '*Alt+F*') -and ($n -notlike '*Alt+E*')) { continue } + if ($e.Current.ControlType.ProgrammaticName -notlike '*Button*') { continue } + $p = $e.GetSupportedPatterns() + if ($p -notcontains [System.Windows.Automation.ExpandCollapsePattern]::Pattern) { continue } + $ec = $e.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern) + $ec.Expand() + } catch { continue } + + Start-Sleep -Milliseconds 700 + foreach ($m in (Sweep)) { + try { + if ($m.Current.ControlType.ProgrammaticName -notlike '*MenuItem*') { continue } + if ($m.Current.Name -notlike '*Ctrl+J*') { continue } + } catch { continue } + if (Press $m) { Start-Sleep -Milliseconds 1400; return $true } + } + # leave nothing hanging open in the user's face if the item was not there + try { $ec.Collapse() } catch { } + } + return $false +} + +# Same reason the sweeps above retry: the app-menu button is not in the tree the instant a client attaches, +# and one miss here used to mean the whole cancel quietly did nothing. +for ($try = 0; $try -lt 3; $try++) { + if (OpenDownloadsPage) { break } + Start-Sleep -Milliseconds 500 +} + +# ── strategy 3: the row's own menu ───────────────────────────────────────────────────────────────────── +# On the downloads page an in-progress row shows only "Copy download link" and "More actions", with Pause +# and Cancel as MenuItems inside that menu. +# +# Chrome only builds the renderer's accessibility tree once a UIA client attaches, and attaching is what +# this script just did — so the first sweep sees browser chrome and no page content at all. Measured: one +# query returned 47 buttons with no downloads rows, a later one had them. +$all = @() +for ($try = 0; $try -lt 6; $try++) { + $all = Sweep + if ((Named $all @('Clear all')).Count -gt 0) { break } + Start-Sleep -Milliseconds 400 +} + +# Find the row FIRST, then its menu button, rather than finding menu buttons and walking up to guess which +# row they belong to. The ancestor walk matched the wrong row and cancelled a download that was already +# finished, which looked like success and changed nothing. +$more = $null +if ($target) { + foreach ($e in $all) { + if ($e.Current.Name -notlike "*$target*") { continue } + foreach ($m in $moreLabels) { + $c = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, $m) + $hit = $e.FindFirst($Desc, $c) + if ($hit) { $more = $hit; break } + } + if ($more) { break } + } +} +# no filename to match on is still safe when there is exactly one row menu on screen +if (-not $more) { + $cands = Named $all $moreLabels + if ($cands.Count -eq 1) { $more = $cands[0] } +} +if (-not $more) { + Write-Output "no row menu or focusable cancel for '$target'; controls seen:" + $all | ForEach-Object { if ($_.Current.Name -and $_.Current.Name.Length -lt 40) { Write-Output (" " + $_.Current.Name) } } + exit 2 +} + +Press $more | Out-Null +Start-Sleep -Milliseconds 800 + +$cancel = $null +foreach ($e in (Named (Sweep) $cancelLabels)) { + if ($e.Current.ControlType.ProgrammaticName -like '*MenuItem*') { $cancel = $e; break } + if (-not $cancel) { $cancel = $e } +} +if (-not $cancel) { Write-Output "menu opened but no cancel item"; exit 2 } +Press $cancel | Out-Null + +# Whether it actually worked is not decided here. Checking the row was tried and was wrong — a cancelled +# row still carries a menu (Copy download link, Delete from history), so a successful cancel reported +# failure. The caller watches the partial file instead, which is the same answer in every language. +exit 0 diff --git a/src/Halo.App/ClaudeCode/AskStore.cs b/src/Halo.App/ClaudeCode/AskStore.cs new file mode 100644 index 0000000..eb97a9b --- /dev/null +++ b/src/Halo.App/ClaudeCode/AskStore.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Halo.ClaudeCode; + +internal sealed record AskOption(string Label, string Description); + +internal sealed record PendingAsk( + string Nonce, + int Pid, + string? Session, + string Tool, + string? Target, + string? Question, + IReadOnlyList Options, + DateTimeOffset ExpiresAt) +{ + internal bool IsQuestion => Tool == "AskUserQuestion"; +} + +internal sealed class AskQueue +{ + private readonly List _items = []; + + internal int Count => _items.Count; + + internal void Observe(PendingAsk ask) + { + foreach (var existing in _items) + if (existing.Nonce == ask.Nonce) return; + _items.Add(ask); + } + + internal PendingAsk? Head(DateTimeOffset now) + { + foreach (var item in _items) + if (now < item.ExpiresAt) return item; + return null; + } + + internal void Remove(string nonce) => _items.RemoveAll(i => i.Nonce == nonce); + + internal IReadOnlyList Nonces() => _items.ConvertAll(i => i.Nonce); + + internal IReadOnlyList Sweep(DateTimeOffset now) + { + var dropped = new List(); + foreach (var item in _items) + if (now >= item.ExpiresAt) dropped.Add(item.Nonce); + foreach (var nonce in dropped) Remove(nonce); + return dropped; + } +} + +internal sealed class AskStore +{ + private readonly string _dir; + private readonly Func _clock; + private readonly AskQueue _queue = new(); + private readonly HashSet _acked = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + private int _version; + + internal AskStore(string dir, Func? clock = null) + { + _dir = dir; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + internal int Version => System.Threading.Volatile.Read(ref _version); + + internal PendingAsk? Pending + { + get { lock (_lock) return _queue.Head(_clock()); } + } + + internal void Rescan() + { + try + { + if (!Directory.Exists(_dir)) return; + var now = _clock(); + string? before; + lock (_lock) before = _queue.Head(now)?.Nonce; + + var onDisk = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in Directory.GetFiles(_dir, "ask-*.json")) + { + var ask = Parse(path); + if (ask is null || now >= ask.ExpiresAt) continue; + onDisk.Add(ask.Nonce); + lock (_lock) _queue.Observe(ask); + + if (_acked.Add(ask.Nonce)) Touch(Path.Combine(_dir, $"ack-{ask.Nonce}")); + } + + List gone; + lock (_lock) + { + gone = [.. _queue.Nonces().Where(n => !onDisk.Contains(n))]; + foreach (var nonce in gone) _queue.Remove(nonce); + } + foreach (var nonce in gone) Forget(nonce); + + List expired; + lock (_lock) expired = [.. _queue.Sweep(now)]; + foreach (var nonce in expired) Forget(nonce); + + string? after; + lock (_lock) after = _queue.Head(now)?.Nonce; + if (before != after) System.Threading.Interlocked.Increment(ref _version); + } + catch { } + } + + internal bool Answer(PendingAsk ask, string label) + { + if (ask.IsQuestion) return Press(ask, label); + try + { + string decision = label; + string reason = $"{label} from the pill"; + var json = new JsonObject + { + ["nonce"] = ask.Nonce, + ["decision"] = decision, + ["reason"] = reason, + }.ToJsonString(); + + string path = Path.Combine(_dir, $"answer-{ask.Nonce}.json"); + string tmp = path + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, path, overwrite: true); + } + catch { } + finally + { + lock (_lock) _queue.Remove(ask.Nonce); + System.Threading.Interlocked.Increment(ref _version); + } + return true; + } + + private bool Press(PendingAsk ask, string label) + { + if (ask.Pid <= 0) { Trace($"no pid for {ask.Nonce}"); return false; } + int index = -1; + for (int i = 0; i < ask.Options.Count && index < 0; i++) + if (string.Equals(ask.Options[i].Label, label, StringComparison.Ordinal)) index = i; + + bool sent = index >= 0 && index < 9 + + ? Interop.ConsoleRead.Type(ask.Pid, (index + 1).ToString()) + : Write(ask, label); + Trace($"{(index >= 0 ? "row " + (index + 1) : "words")} -> pid {ask.Pid} = {sent}"); + if (!sent) return false; + + lock (_lock) _queue.Remove(ask.Nonce); + Forget(ask.Nonce); + try { File.Delete(Path.Combine(_dir, $"ask-{ask.Nonce}.json")); } catch { } + System.Threading.Interlocked.Increment(ref _version); + return true; + } + + private static bool Write(PendingAsk ask, string text) + { + int pid = ask.Pid, rows = ask.Options.Count; + if (rows <= 0 || string.IsNullOrWhiteSpace(text)) return false; + if (!Interop.ConsoleRead.Press(pid, Interop.ConsoleRead.VkDown, rows)) return false; + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try + { + System.Threading.Thread.Sleep(140); + if (!Interop.ConsoleRead.Press(pid, Interop.ConsoleRead.VkEnter)) return; + System.Threading.Thread.Sleep(180); + if (!Interop.ConsoleRead.Type(pid, text)) return; + System.Threading.Thread.Sleep(140); + Interop.ConsoleRead.Press(pid, Interop.ConsoleRead.VkEnter); + } + catch { } + }); + return true; + } + + private static void Trace(string line) + { + try + { + string path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "ask-debug.txt"); + File.AppendAllText(path, $"{DateTime.Now:HH:mm:ss.fff} {line}\n"); + } + catch { } + } + + private void Forget(string nonce) + { + _acked.Remove(nonce); + + Delete(Path.Combine(_dir, $"ack-{nonce}")); + } + + private PendingAsk? Parse(string path) + { + try + { + if (JsonNode.Parse(File.ReadAllText(path)) is not JsonObject o) return null; + string? nonce = o["nonce"]?.GetValue(); + string? tool = o["tool"]?.GetValue(); + if (string.IsNullOrEmpty(nonce) || string.IsNullOrEmpty(tool)) return null; + if (!DateTimeOffset.TryParse(o["expiresAt"]?.GetValue(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var expires)) + return null; + + var options = new List(); + if (o["options"] is JsonArray arr) + foreach (var n in arr) + if (n is JsonObject oo && oo["label"]?.GetValue() is { Length: > 0 } label) + options.Add(new AskOption(label, oo["description"]?.GetValue() ?? "")); + if (options.Count == 0) return null; + + return new PendingAsk( + nonce, + o["pid"] is JsonValue pv && pv.TryGetValue(out var pid) ? pid : 0, + o["session"]?.GetValue(), + tool, + o["target"]?.GetValue(), + o["question"]?.GetValue(), + options, + expires); + } + catch { return null; } + } + + private static void Touch(string path) + { + try { if (!File.Exists(path)) File.WriteAllText(path, ""); } catch { } + } + + private static void Delete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } +} diff --git a/src/Halo.App/ClaudeCode/CcCancel.cs b/src/Halo.App/ClaudeCode/CcCancel.cs new file mode 100644 index 0000000..a375527 --- /dev/null +++ b/src/Halo.App/ClaudeCode/CcCancel.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Halo.ClaudeCode; + +internal static class CcCancel +{ + public static void Request(int pid) + { + try + { + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var candidates = new[] + { + Path.Combine(local, "Halo", "hooks", "Halo.Hooks.exe"), + Path.Combine(AppContext.BaseDirectory, "Halo.Hooks.exe"), + }; + var exe = candidates.FirstOrDefault(File.Exists); + if (exe == null) return; + Process.Start(new ProcessStartInfo(exe, $"cancel {pid}") + { + CreateNoWindow = true, + UseShellExecute = false, + }); + } + catch + { + } + } +} diff --git a/src/Halo.App/ClaudeCode/CompactProgress.cs b/src/Halo.App/ClaudeCode/CompactProgress.cs new file mode 100644 index 0000000..bd1ff48 --- /dev/null +++ b/src/Halo.App/ClaudeCode/CompactProgress.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Halo.ClaudeCode; + +internal static class CompactProgress +{ + + public static volatile int Percent = -1; + public static volatile int Tokens = -1; + public static int Version; + + private const int TypicalSummary = 5700; + + private static int _busy; + private static long _polledAt; + private static int _pid; + private static string? _key; + private static int _peak; + private static int _expect = TypicalSummary; + private static bool _loaded; + + private static readonly string CalibPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "compact-tokens"); + + public static void Poke(int pid, string? key) + { + Load(); + if (pid <= 0) return; + Track(pid, key); + long now = Environment.TickCount64; + if (now - _polledAt < 600) return; + _polledAt = now; + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + var of = _key; + ThreadPool.QueueUserWorkItem(_ => + { + try { Sample(pid, of); } catch { } finally { Volatile.Write(ref _busy, 0); } + }); + } + + public static void Done() + { + if (_pid == 0 && _key is null && Percent < 0 && Tokens < 0) return; + if (_peak >= 400) Save(_peak); + Track(0, null); + } + + internal static bool Track(int pid, string? key) + { + if (pid == _pid && key == _key) return false; + _pid = pid; + _key = key; + _peak = 0; + Percent = -1; + Tokens = -1; + Interlocked.Increment(ref Version); + return true; + } + + private static void Sample(int pid, string? of) + { + + var rows = Interop.ConsoleRead.Tail(pid, 14, below: 2); + int? bar = null, tokens = null; + if (rows is not null) + foreach (var row in rows) + { + if (BarShare(row) is { } b) bar = b; + else if (Streamed(row) is { } n) tokens = n; + } + + Trace(pid, rows, bar, tokens); + if (rows is null) return; + if (of != _key) return; + + int now; + if (bar is { } pct) { Tokens = -1; now = pct; } + else if (tokens is { } t) + { + if (t > _peak) _peak = t; + Tokens = t; + now = Share(t); + } + else return; + + Percent = Percent < 0 ? now : Math.Max(Percent, now); + Interlocked.Increment(ref Version); + } + + internal static int Share(int tokens, int expect = 0) + { + int total = expect > 0 ? expect : _expect; + return total <= 0 || tokens <= 0 ? -1 : (int)Math.Clamp(100L * tokens / total, 1, 99); + } + + private const char Filled = (char)0x25B0, Empty = (char)0x25B1; + + internal static int? BarShare(string? line) + { + if (string.IsNullOrEmpty(line)) return null; + int filled = 0, empty = 0; + foreach (var c in line) + { + if (c == Filled) filled++; + else if (c == Empty) empty++; + } + int total = filled + empty; + return total < 8 ? null : (int)Math.Clamp(100L * filled / total, 0, 100); + } + + private static readonly Regex Tok = new(@"(\d+(?:\.\d+)?)\s*([kK]?)\s*tokens", RegexOptions.Compiled); + + internal static int? Streamed(string? line) + { + if (string.IsNullOrEmpty(line)) return null; + var m = Tok.Match(line); + if (!m.Success) return null; + if (!double.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) return null; + if (m.Groups[2].Value.Length > 0) v *= 1000; + return v is >= 0 and < 100_000_000 ? (int)v : null; + } + + internal static string Caption(int percent, int tokens) + => percent >= 0 ? percent + "%" + : tokens >= 1000 ? (tokens / 1000f).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + "k tok" + : tokens > 0 ? tokens + " tok" + : ""; + + public static string Caption() => Caption(Percent, Tokens); + + private static void Trace(int pid, string[]? rows, int? bar, int? tokens) + { + try + { + string path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", + "compact-debug.txt"); + var sb = new System.Text.StringBuilder(); + sb.Append($"{DateTime.Now:HH:mm:ss.fff} pid={pid} rows={rows?.Length.ToString() ?? "null"} ") + .Append($"bar={bar?.ToString() ?? "-"} tokens={tokens?.ToString() ?? "-"}").AppendLine(); + + if (bar is null && tokens is null && rows is not null) + foreach (var row in rows) + if (row.Length > 0) sb.Append(" | ").AppendLine(row.Length > 110 ? row[..110] : row); + File.AppendAllText(path, sb.ToString()); + } + catch { } + } + + private static void Load() + { + if (_loaded) return; + _loaded = true; + try { if (int.TryParse(File.ReadAllText(CalibPath).Trim(), out var v) && v > 0) _expect = v; } + catch { } + } + + private static void Save(int tokens) + { + _expect = tokens; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(CalibPath)!); + File.WriteAllText(CalibPath, tokens.ToString()); + } + catch { } + } +} diff --git a/src/Halo.App/ClaudeCode/Limits.cs b/src/Halo.App/ClaudeCode/Limits.cs new file mode 100644 index 0000000..99036c3 --- /dev/null +++ b/src/Halo.App/ClaudeCode/Limits.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading; + +namespace Halo.ClaudeCode; + +internal static class Limits +{ + public static float FiveHour = -1, Week = -1; + public static DateTimeOffset FiveHourReset, WeekReset; + public static bool Failed; + public static bool ExtraUsageOn; + public static float CreditsUsed = -1; + public static float CreditsLimit = -1; + public static float CreditsBalance = -1; + + private static readonly string CachePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "usage-cache.json"); + + private static readonly string CredPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", ".credentials.json"); + private static string? _lastToken; + private static FileSystemWatcher? _credWatcher; + + static Limits() + { + try + { + var n = JsonNode.Parse(File.ReadAllText(CachePath)); + FiveHour = n?["fiveHour"]?.GetValue() ?? -1; + Week = n?["week"]?.GetValue() ?? -1; + DateTimeOffset.TryParse(n?["fiveHourReset"]?.GetValue(), out FiveHourReset); + DateTimeOffset.TryParse(n?["weekReset"]?.GetValue(), out WeekReset); + ExtraUsageOn = n?["extraOn"]?.GetValue() ?? false; + CreditsUsed = n?["creditsUsed"]?.GetValue() ?? -1; + CreditsLimit = n?["creditsLimit"]?.GetValue() ?? -1; + CreditsBalance = n?["creditsBalance"]?.GetValue() ?? -1; + if (DateTimeOffset.TryParse(n?["savedAt"]?.GetValue(), out var sa)) + LastSuccess = sa.UtcDateTime; + } + catch { } + + try + { + var dir = Path.GetDirectoryName(CredPath)!; + _credWatcher = new FileSystemWatcher(dir, ".credentials.json") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName, + EnableRaisingEvents = true, + }; + FileSystemEventHandler h = (_, __) => ForceRefresh(); + _credWatcher.Changed += h; + _credWatcher.Created += h; + _credWatcher.Renamed += (_, __) => ForceRefresh(); + } + catch { } + } + + private static void SaveCache() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!); + File.WriteAllText(CachePath, new JsonObject + { + ["fiveHour"] = FiveHour, + ["week"] = Week, + ["fiveHourReset"] = FiveHourReset.ToString("o"), + ["weekReset"] = WeekReset.ToString("o"), + ["extraOn"] = ExtraUsageOn, + ["creditsUsed"] = CreditsUsed, + ["creditsLimit"] = CreditsLimit, + ["creditsBalance"] = CreditsBalance, + ["savedAt"] = DateTimeOffset.UtcNow.ToString("o"), + }.ToJsonString()); + } + catch { } + } + + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; + private static DateTime _last = DateTime.MinValue; + private static TimeSpan _cooldown = TimeSpan.FromSeconds(30); + + private static readonly Timer Heartbeat = + new(_ => Fetch(force: false), null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + private static int _busy; + private static readonly List _opens = new(); + + public static DateTime LastSuccess = DateTime.MinValue; + + public static void Poke() => Fetch(force: false); + + public static void OnPanelOpen() + { + var now = DateTime.UtcNow; + lock (_opens) + { + _opens.Add(now); + _opens.RemoveAll(t => (now - t).TotalSeconds > 60); + if (_opens.Count > 2 && now - LastSuccess < TimeSpan.FromMinutes(5)) return; + } + Fetch(force: false); + } + + public static void ForceRefresh() => Fetch(force: true); + + private static void Fetch(bool force) + { + var now = DateTime.UtcNow; + if (now - _last < (force ? TimeSpan.FromSeconds(5) : _cooldown)) return; + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + _last = now; + ThreadPool.QueueUserWorkItem(_ => { try { Refresh(); } finally { _busy = 0; } }); + } + + private static void Refresh() + { + try + { + var tok = JsonNode.Parse(File.ReadAllText(CredPath))?["claudeAiOauth"]?["accessToken"]?.GetValue(); + if (string.IsNullOrEmpty(tok)) return; + if (tok != _lastToken) + { + + _lastToken = tok; + FiveHour = Week = -1; FiveHourReset = WeekReset = default; Failed = false; + ExtraUsageOn = false; CreditsUsed = CreditsLimit = CreditsBalance = -1; + } + + var req = new HttpRequestMessage(HttpMethod.Get, "https://api.anthropic.com/api/oauth/usage"); + req.Headers.TryAddWithoutValidation("authorization", "Bearer " + tok); + req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20"); + using var resp = Http.Send(req); + if ((int)resp.StatusCode == 429) + { + + Probe(tok); + _cooldown = TimeSpan.FromMinutes(2); + return; + } + _cooldown = TimeSpan.FromSeconds(30); + var root = JsonNode.Parse(resp.Content.ReadAsStream()); + + (float u, DateTimeOffset r) Bucket(string key) + { + var n = root?[key]; + float u = n?["utilization"]?.GetValue() ?? -1; + DateTimeOffset.TryParse(n?["resets_at"]?.GetValue(), out var r); + return (u < 0 ? -1 : u / 100f, r); + } + var (u5, r5) = Bucket("five_hour"); + if (u5 >= 0) { FiveHour = u5; FiveHourReset = r5; } + var (u7, r7) = Bucket("seven_day"); + if (u7 >= 0) { Week = u7; WeekReset = r7; } + + if (root?["extra_usage"] is { } eu) + { + ExtraUsageOn = eu["is_enabled"]?.GetValue() ?? false; + int dec = eu["decimal_places"]?.GetValue() ?? 2; + float div = MathF.Pow(10, dec); + float used = eu["used_credits"]?.GetValue() ?? -1; + CreditsUsed = used < 0 ? -1 : used / div; + float lim = eu["monthly_limit"]?.GetValue() ?? -1; + CreditsLimit = lim <= 0 ? -1 : lim / div; + } + + if (root?["spend"]?["balance"]?["amount_minor"]?.GetValue() is { } bal) + { + int exp = root?["spend"]?["balance"]?["exponent"]?.GetValue() ?? 2; + CreditsBalance = bal / MathF.Pow(10, exp); + } + if (u5 >= 0 || u7 >= 0) { LastSuccess = DateTime.UtcNow; SaveCache(); } + Failed = false; + AdjustHeartbeat(); + } + catch { Failed = true; } + } + + private static void AdjustHeartbeat() + { + var p = TimeSpan.FromSeconds(FiveHour >= 0.99f || Week >= 0.99f ? 60 : 300); + try { Heartbeat.Change(p, p); } catch { } + } + + private static void Probe(string tok) + { + try + { + var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Headers.TryAddWithoutValidation("authorization", "Bearer " + tok); + req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20"); + req.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + req.Content = new System.Net.Http.StringContent( + "{\"model\":\"claude-haiku-4-5-20251001\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", + System.Text.Encoding.UTF8, "application/json"); + using var resp = Http.Send(req); + + float H(string n) => resp.Headers.TryGetValues(n, out var v) + && float.TryParse(System.Linq.Enumerable.First(v), + System.Globalization.CultureInfo.InvariantCulture, out var f) ? f : -1f; + DateTimeOffset R(string n) => resp.Headers.TryGetValues(n, out var v) + && long.TryParse(System.Linq.Enumerable.First(v), out var s) + ? DateTimeOffset.FromUnixTimeSeconds(s) : default; + + float u5 = H("anthropic-ratelimit-unified-5h-utilization"); + float u7 = H("anthropic-ratelimit-unified-7d-utilization"); + if (u5 >= 0) { FiveHour = Math.Min(1f, u5); FiveHourReset = R("anthropic-ratelimit-unified-5h-reset"); } + if (u7 >= 0) { Week = Math.Min(1f, u7); WeekReset = R("anthropic-ratelimit-unified-7d-reset"); } + if (u5 >= 0 || u7 >= 0) { LastSuccess = DateTime.UtcNow; SaveCache(); Failed = false; AdjustHeartbeat(); } + } + catch { Failed = true; } + } +} diff --git a/src/Halo.App/ClaudeCode/NetMon.cs b/src/Halo.App/ClaudeCode/NetMon.cs new file mode 100644 index 0000000..c86c353 --- /dev/null +++ b/src/Halo.App/ClaudeCode/NetMon.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace Halo.ClaudeCode; + +internal static class NetMon +{ + public const int Lost = -1, Empty = -2; + private static readonly int[] _net = CreateBuf(), _api = CreateBuf(); + private static int _idx; + private static DateTime _until = DateTime.MinValue; + private static Thread? _thread; + + public static int Version; + + public static volatile bool ApiDown, NetDown, Slow; + private const int SlowMs = 1500; + private static int _slowStreak; + + private static int[] CreateBuf() + { + var b = new int[24]; + Array.Fill(b, Empty); + return b; + } + + static NetMon() => EnsureThread(); + + public static void Poke() + { + IpCountry.Poke(); + _until = DateTime.UtcNow.AddSeconds(8); + EnsureThread(); + } + + private static void EnsureThread() + { + if (_thread == null) + { + _thread = new Thread(Loop) { IsBackground = true }; + _thread.Start(); + } + } + + public static (int[] net, int[] api) Snapshot() + { + lock (_net) + { + var n = new int[_net.Length]; + var a = new int[_api.Length]; + for (int i = 0; i < _net.Length; i++) + { + n[i] = _net[(_idx + i) % _net.Length]; + a[i] = _api[(_idx + i) % _api.Length]; + } + return (n, a); + } + } + + private static void Loop() + { + var lastBg = DateTime.MinValue; + while (true) + { + + if (DateTime.UtcNow - lastBg > TimeSpan.FromSeconds(10)) + { + lastBg = DateTime.UtcNow; + int apiMs = HttpLatency(HttpApi, "https://api.anthropic.com/v1/messages", fresh: true); + bool apiDown = apiMs == Lost; + int netMs = HttpLatency(HttpNet, "https://www.google.com/generate_204", fresh: true); + bool netDown = apiDown && netMs == Lost; + SetHealth(apiDown, netDown); + bool bad = netMs == Lost || netMs > SlowMs; + _slowStreak = bad ? _slowStreak + 1 : 0; + SetSlow(_slowStreak >= 2); + + RecordSample(netMs, apiMs); + } + if (DateTime.UtcNow < _until) + { + + int apiMs = Lost; + var apiTask = new Thread(() => apiMs = HttpLatency(HttpApi, "https://api.anthropic.com/v1/messages")) { IsBackground = true }; + apiTask.Start(); + int netMs = HttpLatency(HttpNet, "https://www.google.com/generate_204"); + apiTask.Join(2600); + + RecordSample(netMs, apiMs); + Thread.Sleep(700); + } + else Thread.Sleep(300); + } + } + + private static void RecordSample(int netMs, int apiMs) + { + lock (_net) { _net[_idx] = netMs; _api[_idx] = apiMs; _idx = (_idx + 1) % _net.Length; } + Interlocked.Increment(ref Version); + } + + private static void SetHealth(bool apiDown, bool netDown) + { + if (apiDown == ApiDown && netDown == NetDown) return; + ApiDown = apiDown; + NetDown = netDown; + IpCountry.Invalidate(); + Interlocked.Increment(ref Version); + } + + private static void SetSlow(bool slow) + { + if (slow == Slow) return; + Slow = slow; + Interlocked.Increment(ref Version); + } + + private static readonly System.Net.Http.HttpClient HttpApi = new(ProxiedHandler()) + { Timeout = TimeSpan.FromSeconds(2.5) }; + private static readonly System.Net.Http.HttpClient HttpNet = new( + new System.Net.Http.SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), UseProxy = false }) + { Timeout = TimeSpan.FromSeconds(2.5) }; + + internal static string? ProxyUrl => + Environment.GetEnvironmentVariable("HTTPS_PROXY") ?? Environment.GetEnvironmentVariable("HTTP_PROXY") + ?? Environment.GetEnvironmentVariable("HTTPS_PROXY", EnvironmentVariableTarget.User) + ?? Environment.GetEnvironmentVariable("HTTP_PROXY", EnvironmentVariableTarget.User); + + private static System.Net.Http.SocketsHttpHandler ProxiedHandler() + { + var h = new System.Net.Http.SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }; + var proxy = ProxyUrl; + if (!string.IsNullOrEmpty(proxy)) + try { h.Proxy = new System.Net.WebProxy(proxy); h.UseProxy = true; } catch { h.UseProxy = false; } + else + h.UseProxy = false; + return h; + } + + private static int HttpLatency(System.Net.Http.HttpClient http, string url, bool fresh = false) + { + try + { + var sw = Stopwatch.StartNew(); + var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, url); + if (fresh) req.Headers.ConnectionClose = true; + using var resp = http.Send(req, System.Net.Http.HttpCompletionOption.ResponseHeadersRead); + int sc = (int)resp.StatusCode; + return IsDownStatus(sc) ? Lost : (int)sw.ElapsedMilliseconds; + } + catch { return Lost; } + } + + internal static bool IsDownStatus(int statusCode) => + statusCode >= 500 || statusCode == 403 || statusCode == 407 || statusCode == 429; +} + +internal static class IpRep +{ + public static volatile string? ForIp; + public static volatile string? Verdict; + public static volatile string? Abuse; + public static volatile int Sev; + + public static volatile bool Tor, Abuser, Bogon, Vpn, Proxy, Datacenter; + + private static int _busy; + private static readonly System.Net.Http.HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) }; + + public static void Want(string? ip) + { + if (string.IsNullOrEmpty(ip)) return; + if (string.Equals(ForIp, ip, StringComparison.Ordinal)) return; + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + ThreadPool.QueueUserWorkItem(_ => + { + try { Fetch(ip); } catch { } finally { Volatile.Write(ref _busy, 0); } + }); + } + + internal static string? AbuseLabel(string? raw) + { + if (raw is not { Length: > 0 }) return null; + int open = raw.IndexOf('('), close = open < 0 ? -1 : raw.IndexOf(')', open); + if (open < 0 || close <= open) return null; + string s = raw.Substring(open + 1, close - open - 1).Trim().ToLowerInvariant(); + return s.Length == 0 ? null : s; + } + + internal static (string verdict, int sev) Classify(bool tor, bool abuser, bool bogon, bool vpn, + bool proxy, bool datacenter, bool mobile, string? abuse) + { + var (verdict, sev) = + tor ? ("flagged: tor", 3) + : abuser ? ("flagged: abuse", 3) + : bogon ? ("flagged: bogon", 3) + : vpn ? ("vpn, recognised", 2) + : proxy ? ("proxy, recognised", 2) + : datacenter ? ("datacenter", 1) + : mobile ? ("mobile", 0) + : ("residential", 0); + + if (sev < 2 && abuse is "high" or "very high") sev = 2; + return (verdict, sev); + } + + internal static int Score(bool tor, bool abuser, bool bogon, bool vpn, bool proxy, bool datacenter, + string? abuse, bool split, bool dnsLeak) + { + int s = 100; + if (tor) s -= 55; + if (abuser) s -= 45; + if (bogon) s -= 45; + if (vpn || proxy) s -= 22; + if (datacenter) s -= 14; + if (abuse == "very high") s -= 22; + else if (abuse == "high") s -= 14; + if (split) s -= 12; + if (dnsLeak) s -= 20; + return Math.Clamp(s, 0, 100); + } + + private static void Fetch(string ip) + { + string body; + try { body = Http.GetStringAsync("https://api.ipapi.is/?q=" + Uri.EscapeDataString(ip)).Result; } + catch { return; } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + var r = doc.RootElement; + bool Flag(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.True; + + string? abuse = AbuseLabel( + r.TryGetProperty("company", out var co) + && co.TryGetProperty("abuser_score", out var sc) ? sc.GetString() : null); + + bool tor = Flag("is_tor"), abuser = Flag("is_abuser"), bogon = Flag("is_bogon"); + bool vpn = Flag("is_vpn"), proxy = Flag("is_proxy"), dc = Flag("is_datacenter"); + var (verdict, sev) = Classify(tor, abuser, bogon, vpn, proxy, dc, Flag("is_mobile"), abuse); + Tor = tor; Abuser = abuser; Bogon = bogon; Vpn = vpn; Proxy = proxy; Datacenter = dc; + + Verdict = verdict; + Abuse = abuse; + Sev = sev; + ForIp = ip; + Interlocked.Increment(ref NetMon.Version); + } + catch { } + } +} + +internal static class DnsLeak +{ + public static volatile string? ForIp; + public static volatile bool Running; + public static volatile bool Done; + public static volatile int Resolvers; + public static volatile string? Where; + public static volatile bool Leaking; + + private static int _busy; + private static readonly System.Net.Http.HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + public static void Retest() + { + ForIp = null; + Done = false; + Interlocked.Increment(ref NetMon.Version); + } + + public static void Want(string? exitIp, string? exitCc) + { + if (string.IsNullOrEmpty(exitIp) || string.IsNullOrEmpty(exitCc)) return; + if (string.Equals(ForIp, exitIp, StringComparison.Ordinal)) return; + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + Running = true; + ThreadPool.QueueUserWorkItem(_ => + { + try { Run(exitIp!, exitCc!); } + catch { } + finally { Running = false; Volatile.Write(ref _busy, 0); } + }); + } + + private static void Run(string exitIp, string exitCc) + { + string id; + try { id = Http.GetStringAsync("https://bash.ws/id").Result.Trim(); } + catch { return; } + if (id.Length == 0) return; + + for (int i = 1; i <= 6; i++) + { + try { System.Net.Dns.GetHostEntry($"{i}.{id}.bash.ws"); } + catch { } + } + + string body; + try { body = Http.GetStringAsync($"https://bash.ws/dnsleak/test/{id}?json").Result; } + catch { return; } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + var seen = new List(); + int count = 0; + foreach (var e in doc.RootElement.EnumerateArray()) + { + if (!e.TryGetProperty("type", out var t) || t.GetString() != "dns") continue; + count++; + var cc = (e.TryGetProperty("country", out var c) ? c.GetString() : null)?.ToUpperInvariant(); + if (cc is { Length: > 0 } && !seen.Contains(cc)) seen.Add(cc); + } + if (count == 0) return; + + Resolvers = count; + Where = string.Join(", ", seen); + + Leaking = seen.Count > 0 && seen.Exists(c => !string.Equals(c, exitCc, StringComparison.OrdinalIgnoreCase)); + Done = true; + ForIp = exitIp; + Interlocked.Increment(ref NetMon.Version); + } + catch { } + } +} + +internal static class IpCountry +{ + public static volatile System.Drawing.Bitmap? Flag; + + public static volatile string? Ip, Cc, Isp, Asn; + + public static volatile string? ApiIp, ApiCc; + + public static bool Split => Ip is { Length: > 0 } a && ApiIp is { Length: > 0 } b + && !string.Equals(a, b, StringComparison.Ordinal); + + private static Timer? _timer; + private static readonly System.Net.Http.HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) }; + private static System.Net.Http.HttpClient? _viaProxy; + + public static void Poke() => _timer ??= new Timer(_ => Refresh(), null, 0, 300_000); + + public static void Invalidate() => _timer?.Change(3_000, 300_000); + + private const string Fields = "https://ipwho.is/?fields=ip,country_code,connection"; + + private static (string ip, string cc, string isp, string asn)? Ask(System.Net.Http.HttpClient http) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(http.GetStringAsync(Fields).Result); + var ip = doc.RootElement.GetProperty("ip").GetString(); + var cc = doc.RootElement.GetProperty("country_code").GetString(); + if (string.IsNullOrEmpty(ip) || string.IsNullOrEmpty(cc)) return null; + string isp = "", asn = ""; + if (doc.RootElement.TryGetProperty("connection", out var conn)) + { + isp = (conn.TryGetProperty("isp", out var i) ? i.GetString() : null) + ?? (conn.TryGetProperty("org", out var o) ? o.GetString() : null) ?? ""; + if (conn.TryGetProperty("asn", out var an) && an.TryGetInt32(out int asnNum) && asnNum > 0) + asn = "AS" + asnNum; + } + return (ip, cc, isp, asn); + } + catch { return null; } + } + + private static void Refresh() + { + var direct = Ask(Http); + if (direct is not { } d) return; + bool changed = d.ip != Ip; + Ip = d.ip; + Cc = d.cc.ToUpperInvariant(); + Isp = d.isp; + Asn = d.asn; + + var proxy = NetMon.ProxyUrl; + if (!string.IsNullOrEmpty(proxy)) + { + try + { + _viaProxy ??= new System.Net.Http.HttpClient(new System.Net.Http.SocketsHttpHandler + { Proxy = new System.Net.WebProxy(proxy), UseProxy = true }) + { Timeout = TimeSpan.FromSeconds(8) }; + var via = Ask(_viaProxy); + ApiIp = via?.ip; + ApiCc = via?.cc.ToUpperInvariant(); + } + catch { ApiIp = null; ApiCc = null; } + } + else { ApiIp = null; ApiCc = null; } + + if (changed || Flag is null) + { + try + { + + var png = Http.GetByteArrayAsync($"https://flagcdn.com/w320/{d.cc.ToLowerInvariant()}.png").Result; + Flag = new System.Drawing.Bitmap(new System.IO.MemoryStream(png)); + } + catch { } + } + Interlocked.Increment(ref NetMon.Version); + } +} diff --git a/src/Halo.App/ClaudeCode/Status.cs b/src/Halo.App/ClaudeCode/Status.cs new file mode 100644 index 0000000..02be21a --- /dev/null +++ b/src/Halo.App/ClaudeCode/Status.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Halo.ClaudeCode; + +internal sealed class CcSession +{ + public long ContextUsed { get; set; } + public long ContextMax { get; set; } = 200000; + public long PromptTokens { get; set; } +} + +internal sealed class CcUsage +{ + public double FiveHourPct { get; set; } + public string? FiveHourResetsAt { get; set; } + public double WeeklyPct { get; set; } + public string? WeeklyResetsAt { get; set; } +} + +internal sealed class CcStatus +{ + public string? Name { get; set; } + public string? Icon { get; set; } + public string? State { get; set; } + public string? Cwd { get; set; } + public int Pid { get; set; } + public int ConsolePid { get; set; } + public string? CurrentTool { get; set; } + public string? ToolTarget { get; set; } + public string? LastPrompt { get; set; } + public string? StartedAt { get; set; } + public string? Message { get; set; } + public string? CompactedAt { get; set; } + public CcSession? Session { get; set; } + public CcUsage? Usage { get; set; } + public string? UpdatedAt { get; set; } +} + +internal sealed class StatusStore +{ + private static readonly TimeSpan ProcessStartTolerance = TimeSpan.FromSeconds(2); + + private static readonly JsonSerializerOptions Opts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + public const int MaxSessions = 4; + + private readonly string _path; + private readonly string? _appPath; + private readonly Func _processStartedAt; + private readonly Func _clock; + private readonly FileSystemWatcher? _watcher; + + private CcStatus? _cli, _app; + + private Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly string?[] _slotPaths = new string?[MaxSessions]; + private readonly (CcStatus? live, DateTimeOffset at, int version)[] _slotCache + = new (CcStatus?, DateTimeOffset, int)[MaxSessions]; + private CcStatus? _selected; + private DateTimeOffset _selectedAt = DateTimeOffset.MinValue; + private int _selectedVersion = -1; + + public CcStatus? Current + { + get + { + var now = _clock(); + if (_selectedVersion != Version || now - _selectedAt > TimeSpan.FromSeconds(1)) + { + _selected = IsLiveStatus(_cli, _processStartedAt, now) ? _cli + : IsLiveStatus(_app, _processStartedAt, now) ? _app + : _cli ?? _app; + _selectedAt = now; + _selectedVersion = Version; + } + return _selected; + } + } + + public int Version { get; private set; } + + private bool _live; + private DateTimeOffset _liveAt = DateTimeOffset.MinValue; + private int _liveVersion = -1; + + public bool IsLive + { + get + { + var now = _clock(); + if (_liveVersion != Version || now - _liveAt > TimeSpan.FromSeconds(1)) + { + _live = IsLiveStatus(Current, _processStartedAt, now); + _liveAt = now; + _liveVersion = Version; + } + return _live; + } + } + + public static string Directory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "notch"); + + public StatusStore() + : this(Path.Combine(Directory, "status.json"), GetProcessStartedAt, watchFiles: true, + appPath: Path.Combine(Directory, "app.json")) + { + } + + internal StatusStore(string path, Func processStartedAt, bool watchFiles, + Func? clock = null, string? appPath = null) + { + _path = path; + _appPath = appPath; + _processStartedAt = processStartedAt; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + System.IO.Directory.CreateDirectory(directory); + Load(); + + if (!watchFiles) + return; + + _watcher = new FileSystemWatcher(Path.GetDirectoryName(_path)!, "*.json") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + EnableRaisingEvents = true, + }; + _watcher.Changed += (_, _) => Load(); + _watcher.Created += (_, _) => Load(); + _watcher.Deleted += (_, _) => Load(); + _watcher.Renamed += (_, _) => Load(); + + _poll = new System.Threading.Timer(_ => Load(), null, 1000, 1000); + } + + private readonly System.Threading.Timer? _poll; + + private static bool IsLiveStatus(CcStatus? status, Func processStartedAt, DateTimeOffset now) + { + if (status is null) + return false; + + if (!DateTimeOffset.TryParse(status.UpdatedAt, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var updatedAt)) + return false; + + if (status.Pid > 0) + { + var startedAt = ProcessStartTime(status.Pid, processStartedAt); + return startedAt.HasValue && startedAt.Value <= updatedAt + ProcessStartTolerance; + } + + if (status.Pid < 0) + return false; + + if (status.State is not ("working" or "waiting" or "waiting_input" or "compacting")) + return false; + + return updatedAt >= now.AddSeconds(-30); + } + + private static DateTimeOffset? ProcessStartTime(int pid, Func processStartedAt) + { + try + { + return processStartedAt(pid); + } + catch (ArgumentException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + catch (Win32Exception) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + internal static DateTimeOffset? GetProcessStartedAt(int pid) + { + if (pid <= 0) + return null; + + using var process = Process.GetProcessById(pid); + return process.HasExited ? null : new DateTimeOffset(process.StartTime); + } + + private readonly object _loadGate = new(); + private void Load() + { + lock (_loadGate) + try + { + var dir = Path.GetDirectoryName(_path)!; + var pattern = Path.GetFileNameWithoutExtension(_path) + "*.json"; + var next = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in System.IO.Directory.EnumerateFiles(dir, pattern)) + { + _files.TryGetValue(p, out var prev); + if (Read(p, prev) is { } st) next[p] = st; + } + if (_appPath is not null) + { + _files.TryGetValue(_appPath, out var prev); + _app = Read(_appPath, prev); + if (_app is not null) next[_appPath] = _app; + } + _files = next; + AssignSlots(_clock()); + + _cli = LiveFiles(_clock()) + .Where(kv => !string.Equals(kv.Key, _appPath, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(kv => kv.Value.UpdatedAt, StringComparer.Ordinal) + .Select(kv => kv.Value).FirstOrDefault(); + Version++; + } + catch + { + } + + try { AfterLoad?.Invoke(); } catch { } + } + + internal Action? AfterLoad; + + private IEnumerable> LiveFiles(DateTimeOffset now) => + _files.Where(kv => IsLiveStatus(kv.Value, _processStartedAt, now)) + .GroupBy(kv => kv.Value.Pid > 0 ? kv.Value.Pid.ToString() : kv.Key) + .Select(g => g.OrderByDescending(kv => kv.Value.UpdatedAt, StringComparer.Ordinal).First()); + + private void AssignSlots(DateTimeOffset now) + { + var live = LiveFiles(now).Select(kv => kv.Key).ToArray(); + for (int i = 0; i < _slotPaths.Length; i++) + if (_slotPaths[i] is { } p && Array.FindIndex(live, x => string.Equals(x, p, StringComparison.OrdinalIgnoreCase)) < 0) + _slotPaths[i] = null; + foreach (var p in live) + { + if (Array.FindIndex(_slotPaths, x => string.Equals(x, p, StringComparison.OrdinalIgnoreCase)) >= 0) + continue; + int free = Array.IndexOf(_slotPaths, null); + if (free < 0) break; + _slotPaths[free] = p; + } + } + + public int LiveSessions() + { + int n = 0; + for (int i = 0; i < MaxSessions; i++) + if (SessionLive(i) is not null) n++; + return n; + } + + public CcStatus? SessionLive(int slot) + { + var now = _clock(); + ref var c = ref _slotCache[slot]; + if (c.version != Version || now - c.at > TimeSpan.FromSeconds(1)) + { + var st = _slotPaths[slot] is { } p && _files.TryGetValue(p, out var s) ? s : null; + c = (IsLiveStatus(st, _processStartedAt, now) ? st : null, now, Version); + } + return c.live; + } + + private static CcStatus? Read(string path, CcStatus? previous) + { + try + { + if (!File.Exists(path)) return null; + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return JsonSerializer.Deserialize(fs, Opts) ?? previous; + } + catch + { + return previous; + } + } +} diff --git a/src/Halo.App/Codex/CodexDesktopCancel.cs b/src/Halo.App/Codex/CodexDesktopCancel.cs new file mode 100644 index 0000000..3ef1ecd --- /dev/null +++ b/src/Halo.App/Codex/CodexDesktopCancel.cs @@ -0,0 +1,69 @@ +using System; +using System.Runtime.InteropServices; + +namespace Halo.Codex; + +internal static class CodexDesktopCancel +{ + internal const uint WmKeyDown = 0x0100; + internal const uint WmKeyUp = 0x0101; + internal const int VkEscape = 0x1B; + private const int SwRestore = 9; + + internal static IntPtr RootWindow(IntPtr handle) => GetAncestor(handle, 2); + + internal static bool Post(IntPtr handle, uint message, IntPtr wParam, IntPtr lParam) + { + if (message == WmKeyDown) + { + if (IsIconic(handle)) ShowWindow(handle, SwRestore); + SetForegroundWindow(handle); + System.Threading.Thread.Sleep(80); + return SendKey(up: false); + } + return SendKey(up: true); + } + + private static bool SendKey(bool up) + { + var input = new INPUT + { + type = 1, + ki = new KEYBDINPUT { wVk = VkEscape, dwFlags = up ? 2u : 0u }, + }; + return SendInput(1, new[] { input }, Marshal.SizeOf()) == 1; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KEYBDINPUT + { + public ushort wVk; + public ushort wScan; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT + { + public uint type; + public KEYBDINPUT ki; + public long _pad; + } + + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr handle, uint flags); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr handle); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr handle, int cmd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint count, INPUT[] inputs, int size); +} diff --git a/src/Halo.App/Codex/CodexDesktopRuntime.cs b/src/Halo.App/Codex/CodexDesktopRuntime.cs new file mode 100644 index 0000000..272a6ce --- /dev/null +++ b/src/Halo.App/Codex/CodexDesktopRuntime.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Halo.Codex; + +internal readonly record struct CodexDesktopPresence(bool Running, DateTimeOffset StartedAt); + +internal sealed record CodexDesktopWindow( + string ProcessName, string ExecutablePath, IntPtr Handle, DateTimeOffset StartedAt); + +internal sealed class CodexDesktopRuntime +{ + private static readonly TimeSpan ProbeLifetime = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan CancelInterval = TimeSpan.FromSeconds(1); + + private readonly Func> _scan; + private readonly Func _post; + private readonly Func _clock; + private readonly object _gate = new(); + private CodexDesktopWindow? _cachedWindow; + private DateTimeOffset _probeExpiresAt = DateTimeOffset.MinValue; + private DateTimeOffset _nextCancelAt = DateTimeOffset.MinValue; + + internal static CodexDesktopRuntime Shared { get; } = new(); + + internal CodexDesktopRuntime() + : this(Scan, CodexDesktopCancel.Post, static () => DateTimeOffset.UtcNow) + { + } + + internal CodexDesktopRuntime( + Func> scan, + Func post, + Func clock) + { + _scan = scan; + _post = post; + _clock = clock; + } + + internal CodexDesktopPresence Presence + { + get + { + var window = Probe(_clock()); + return window is null ? new CodexDesktopPresence(false, default) : + new CodexDesktopPresence(true, window.StartedAt); + } + } + + internal bool TryCancel() + { + var now = _clock(); + var window = Probe(now); + if (window is null) + return false; + + lock (_gate) + { + if (now < _nextCancelAt) + return false; + + _nextCancelAt = now.Add(CancelInterval); + } + + var key = new IntPtr(CodexDesktopCancel.VkEscape); + return _post(window.Handle, CodexDesktopCancel.WmKeyDown, key, IntPtr.Zero) & + _post(window.Handle, CodexDesktopCancel.WmKeyUp, key, IntPtr.Zero); + } + + private CodexDesktopWindow? Probe(DateTimeOffset now) + { + lock (_gate) + { + if (now < _probeExpiresAt) + return _cachedWindow; + + _cachedWindow = FindWindow(_scan()); + _probeExpiresAt = now.Add(ProbeLifetime); + return _cachedWindow; + } + } + + private static CodexDesktopWindow? FindWindow(IReadOnlyList windows) + { + foreach (var window in windows) + if (window.Handle != IntPtr.Zero && IsCodexProcess(window)) + return window; + + return null; + } + + private static bool IsCodexProcess(CodexDesktopWindow window) => + (string.Equals(window.ProcessName, "ChatGPT", StringComparison.OrdinalIgnoreCase) || + string.Equals(window.ProcessName, "Codex", StringComparison.OrdinalIgnoreCase)) && + window.ExecutablePath.Contains("\\WindowsApps\\OpenAI.Codex_", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList Scan() + { + var windows = new List(); + foreach (var processName in new[] { "ChatGPT", "Codex" }) + { + foreach (var process in Process.GetProcessesByName(processName)) + using (process) + { + try + { + var handle = process.MainWindowHandle; + var executablePath = process.MainModule?.FileName; + if (handle == IntPtr.Zero || string.IsNullOrEmpty(executablePath)) + continue; + + handle = CodexDesktopCancel.RootWindow(handle); + if (handle == IntPtr.Zero) + continue; + + windows.Add(new CodexDesktopWindow( + process.ProcessName, + executablePath, + handle, + new DateTimeOffset(process.StartTime.ToUniversalTime()))); + } + catch (InvalidOperationException) + { + } + catch (System.ComponentModel.Win32Exception) + { + } + catch (NotSupportedException) + { + } + } + } + return windows; + } +} diff --git a/src/Halo.App/Codex/Limits.cs b/src/Halo.App/Codex/Limits.cs new file mode 100644 index 0000000..10c19cd --- /dev/null +++ b/src/Halo.App/Codex/Limits.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace Halo.Codex; + +internal sealed record CodexCachedLimits(CodexLimit? Primary, CodexLimit? Secondary); + +internal sealed class CodexLimitsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + private readonly string _cachePath; + private readonly Func _clock; + + internal CodexCachedLimits? Current { get; private set; } + internal DateTimeOffset LastSuccess { get; private set; } + internal int Version { get; private set; } + + internal CodexLimitsStore(string cachePath, Func? clock = null) + { + _cachePath = cachePath; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + Load(); + } + + internal void Update(CodexSnapshot snapshot) + { + var primary = Valid(snapshot.PrimaryLimit) ? snapshot.PrimaryLimit : Current?.Primary; + var secondary = Valid(snapshot.SecondaryLimit) ? snapshot.SecondaryLimit : Current?.Secondary; + if (primary is null && secondary is null) + return; + + var next = new CodexCachedLimits(primary, secondary); + if (Equals(Current, next)) + return; + + Current = next; + Version++; + LastSuccess = _clock(); + Save(); + } + + private void Load() + { + try + { + var cache = JsonSerializer.Deserialize(File.ReadAllText(_cachePath), JsonOptions); + var primary = Valid(cache?.Primary) ? cache!.Primary : null; + var secondary = Valid(cache?.Secondary) ? cache!.Secondary : null; + if (primary is null && secondary is null) + return; + + Current = new CodexCachedLimits(primary, secondary); + LastSuccess = cache?.SavedAt ?? DateTimeOffset.MinValue; + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + } + + private void Save() + { + var directory = Path.GetDirectoryName(_cachePath); + if (string.IsNullOrEmpty(directory)) + return; + + var temporaryPath = _cachePath + ".tmp"; + try + { + Directory.CreateDirectory(directory); + var cache = new CacheFile(Current?.Primary, Current?.Secondary, LastSuccess); + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(cache, JsonOptions)); + File.Move(temporaryPath, _cachePath, overwrite: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + finally + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private static bool Valid(CodexLimit? limit) => + limit is not null && limit.UsedPercent >= 0 && limit.UsedPercent <= 100; + + private sealed record CacheFile(CodexLimit? Primary, CodexLimit? Secondary, DateTimeOffset? SavedAt); +} + +internal static class CodexLimits +{ + private static readonly CodexLimitsStore Cache = new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "codex-limits-cache.json")); + private static CodexStatusStore? _statusStore; + + internal static CodexCachedLimits? Current => Cache.Current; + internal static DateTimeOffset LastSuccess => Cache.LastSuccess; + internal static int Version => Cache.Version; + + internal static void Attach(CodexStatusStore statusStore) => _statusStore = statusStore; + + internal static void UpdateFrom(CodexSnapshot? snapshot) + { + if (snapshot is not null) + Cache.Update(snapshot); + } + + internal static void ForceRefresh() => _statusStore?.ForceRefresh(); + + internal static float PrimaryFrac => Current?.Primary is { } primary ? (float)(primary.UsedPercent / 100) : -1; + internal static float SecondaryFrac => Current?.Secondary is { } secondary ? (float)(secondary.UsedPercent / 100) : -1; + internal static DateTimeOffset PrimaryReset => Current?.Primary?.ResetsAt ?? DateTimeOffset.MinValue; + internal static DateTimeOffset SecondaryReset => Current?.Secondary?.ResetsAt ?? DateTimeOffset.MinValue; + internal static void OnPanelOpen() => ForceRefresh(); +} diff --git a/src/Halo.App/Codex/NetMon.cs b/src/Halo.App/Codex/NetMon.cs new file mode 100644 index 0000000..6826d66 --- /dev/null +++ b/src/Halo.App/Codex/NetMon.cs @@ -0,0 +1,140 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace Halo.Codex; + +internal static class CodexNetMon +{ + public const int Lost = -1, Empty = -2; + + private const string ApiTarget = "https://chatgpt.com/backend-api/codex/responses"; + private const string NetTarget = "https://www.google.com/generate_204"; + + private static readonly int[] _net = CreateBuffer(), _api = CreateBuffer(); + private static int _index; + private static DateTime _until = DateTime.MinValue; + private static Thread? _thread; + + internal static int Version; + internal static volatile bool ApiDown, NetDown; + + static CodexNetMon() => EnsureThread(); + + internal static void Poke() + { + _until = DateTime.UtcNow.AddSeconds(8); + EnsureThread(); + } + + private static void EnsureThread() + { + if (_thread is null) + { + _thread = new Thread(Loop) { IsBackground = true }; + _thread.Start(); + } + } + + internal static (int[] net, int[] api) Snapshot() + { + lock (_net) + { + var net = new int[_net.Length]; + var api = new int[_api.Length]; + for (var i = 0; i < _net.Length; i++) + { + net[i] = _net[(_index + i) % _net.Length]; + api[i] = _api[(_index + i) % _api.Length]; + } + return (net, api); + } + } + + private static int[] CreateBuffer() + { + var buffer = new int[24]; + Array.Fill(buffer, Empty); + return buffer; + } + + private static void Loop() + { + var lastBackgroundProbe = DateTime.MinValue; + while (true) + { + + if (DateTime.UtcNow - lastBackgroundProbe > TimeSpan.FromSeconds(10)) + { + lastBackgroundProbe = DateTime.UtcNow; + var apiMs = HttpLatency(ApiTarget, fresh: true); + var apiDown = apiMs == Lost; + var netMs = HttpLatency(NetTarget, fresh: true); + var netDown = apiDown && netMs == Lost; + SetHealth(apiDown, netDown); + + RecordSample(netMs, apiMs); + } + if (DateTime.UtcNow < _until) + { + var apiMilliseconds = Lost; + var apiProbe = new Thread(() => apiMilliseconds = HttpLatency(ApiTarget)) { IsBackground = true }; + apiProbe.Start(); + var netMilliseconds = HttpLatency(NetTarget); + apiProbe.Join(2600); + + RecordSample(netMilliseconds, apiMilliseconds); + Thread.Sleep(700); + } + else + { + Thread.Sleep(300); + } + } + } + + private static void RecordSample(int netMs, int apiMs) + { + lock (_net) + { + _net[_index] = netMs; + _api[_index] = apiMs; + _index = (_index + 1) % _net.Length; + } + Interlocked.Increment(ref Version); + } + + private static void SetHealth(bool apiDown, bool netDown) + { + if (apiDown == ApiDown && netDown == NetDown) + return; + + ApiDown = apiDown; + NetDown = netDown; + Interlocked.Increment(ref Version); + } + + private static readonly System.Net.Http.HttpClient Http = new( + new System.Net.Http.SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + { Timeout = TimeSpan.FromSeconds(2.5) }; + + private static int HttpLatency(string url, bool fresh = false) + { + try + { + var stopwatch = Stopwatch.StartNew(); + var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, url); + if (fresh) request.Headers.ConnectionClose = true; + using var response = Http.Send(request, System.Net.Http.HttpCompletionOption.ResponseHeadersRead); + int sc = (int)response.StatusCode; + return IsDownStatus(sc) ? Lost : (int)stopwatch.ElapsedMilliseconds; + } + catch + { + return Lost; + } + } + + internal static bool IsDownStatus(int statusCode) => + statusCode >= 500 || statusCode == 403 || statusCode == 407 || statusCode == 429; +} diff --git a/src/Halo.App/Codex/Status.cs b/src/Halo.App/Codex/Status.cs new file mode 100644 index 0000000..61a933f --- /dev/null +++ b/src/Halo.App/Codex/Status.cs @@ -0,0 +1,828 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; + +namespace Halo.Codex; + +internal enum CodexSurface { Cli, Desktop } + +internal sealed record CodexLimit(double UsedPercent, int WindowMinutes, DateTimeOffset? ResetsAt); + +[Flags] +internal enum CodexSnapshotFields +{ + None = 0, + ContextUsed = 1 << 0, + ContextMax = 1 << 1, + PromptTokens = 1 << 2, + PrimaryLimit = 1 << 3, + SecondaryLimit = 1 << 4, +} + +internal sealed record CodexSnapshot( + CodexSurface Source, string State, string? CurrentTool, DateTimeOffset? StartedAt, + DateTimeOffset? CompactedAt, string? Message, string? Cwd, int Pid, int ConsolePid, + long ContextUsed, long ContextMax, long PromptTokens, CodexLimit? PrimaryLimit, + CodexLimit? SecondaryLimit, DateTimeOffset UpdatedAt, bool ProcessAlive) +{ + internal CodexSnapshotFields PresentFields { get; init; } +} + +internal static class CodexRollout +{ + internal static CodexSnapshot? Parse(string path) + { + if (!File.Exists(path)) + return null; + + try + { + var state = "idle"; + string? currentTool = null; + DateTimeOffset? startedAt = null; + DateTimeOffset? compactedAt = null; + string? message = null; + string? cwd = null; + var source = CodexSurface.Cli; + long contextUsed = 0; + long contextMax = 0; + long promptTokens = 0; + CodexLimit? primaryLimit = null; + CodexLimit? secondaryLimit = null; + var presentFields = CodexSnapshotFields.None; + var updatedAt = DateTimeOffset.MinValue; + var sawEvent = false; + + foreach (var line in ReadSharedLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + var payload = Property(root, "payload") ?? root; + var eventType = String(payload, "type") ?? String(root, "type"); + var timestamp = Timestamp(root, "timestamp"); + + sawEvent = true; + if (timestamp is { } value && value > updatedAt) + updatedAt = value; + + switch (eventType) + { + case "session_meta": + cwd ??= String(payload, "cwd"); + if (String(payload, "originator")?.Contains("Desktop", StringComparison.OrdinalIgnoreCase) == true) + source = CodexSurface.Desktop; + break; + case "task_started": + state = "working"; + startedAt = timestamp; + if (Number(payload, "model_context_window") is { } startedContextMax) + { + contextMax = startedContextMax; + presentFields |= CodexSnapshotFields.ContextMax; + } + break; + case "custom_tool_call": + case "function_call": + state = "working"; + currentTool = ShortTool(String(payload, "name") ?? String(payload, "tool_name") ?? String(payload, "tool")); + break; + case "function_call_output": + if (state == "working") currentTool = null; + break; + case "request_user_input": + case "request_user_approval": + case "approval": + state = "waiting_input"; + message = String(payload, "message") ?? String(payload, "text") ?? String(payload, "prompt") ?? message; + break; + case "task_complete": + state = "idle"; + currentTool = null; + startedAt = null; + break; + case "pre_compact": + case "precompact": + case "PreCompact": + state = "compacting"; + startedAt ??= timestamp; + break; + case "post_compact": + case "postcompact": + case "PostCompact": + state = "working"; + compactedAt = timestamp; + break; + case "token_count": + var info = Property(payload, "info"); + if (info is { } tokenInfo) + { + if (Number(tokenInfo, "model_context_window") is { } tokenContextMax) + { + contextMax = tokenContextMax; + presentFields |= CodexSnapshotFields.ContextMax; + } + var lastTokens = TotalTokens(Property(tokenInfo, "last_token_usage")); + var contextTokens = lastTokens ?? TotalTokens(Property(tokenInfo, "total_token_usage")); + if (contextTokens is { } currentTokens) + { + contextUsed = currentTokens; + presentFields |= CodexSnapshotFields.ContextUsed; + } + if (lastTokens is { } turnTokens) + { + promptTokens = turnTokens; + presentFields |= CodexSnapshotFields.PromptTokens; + } + } + + var limits = Property(payload, "rate_limits"); + if (limits is { } rateLimits) + { + if (Limit(Property(rateLimits, "primary"), timestamp) is { } primary) + { + primaryLimit = primary; + presentFields |= CodexSnapshotFields.PrimaryLimit; + } + if (Limit(Property(rateLimits, "secondary"), timestamp) is { } secondary) + { + secondaryLimit = secondary; + presentFields |= CodexSnapshotFields.SecondaryLimit; + } + } + break; + } + } + catch (JsonException) + { + + } + } + + if (!sawEvent) + return null; + + if (updatedAt == DateTimeOffset.MinValue) + updatedAt = File.GetLastWriteTimeUtc(path); + + return new CodexSnapshot( + source, state, currentTool, startedAt, compactedAt, message, cwd, 0, 0, + contextUsed, contextMax, promptTokens, primaryLimit, secondaryLimit, updatedAt, false) + { + PresentFields = presentFields, + }; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + private static CodexLimit? Limit(JsonElement? value, DateTimeOffset? timestamp) + { + if (value is not { } limit) + return null; + + var usedPercent = NumberDouble(limit, "used_percent"); + var windowMinutes = Number(limit, "window_minutes"); + if (usedPercent is null || windowMinutes is null) + return null; + + var resetsAt = Timestamp(limit, "resets_at"); + if (resetsAt is null && Number(limit, "resets_in_seconds") is { } seconds) + resetsAt = (timestamp ?? DateTimeOffset.UtcNow).AddSeconds(seconds); + + return new CodexLimit(usedPercent.Value, checked((int)windowMinutes.Value), resetsAt); + } + + private static long? TotalTokens(JsonElement? usage) => usage is { } value ? Number(value, "total_tokens") : null; + + private static string? ShortTool(string? tool) => tool?.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + + private static JsonElement? Property(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) ? value : null; + + private static string? String(JsonElement element, string name) => + Property(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null; + + private static long? Number(JsonElement element, string name) => + Property(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : + Property(element, name) is { ValueKind: JsonValueKind.String } text && long.TryParse(text.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : null; + + private static double? NumberDouble(JsonElement element, string name) => + Property(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetDouble(out var number) ? number : + Property(element, name) is { ValueKind: JsonValueKind.String } text && double.TryParse(text.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out number) ? number : null; + + private static DateTimeOffset? Timestamp(JsonElement element, string name) + { + var value = Property(element, name); + if (value is { ValueKind: JsonValueKind.Number } && value.Value.TryGetInt64(out var unix)) + return DateTimeOffset.FromUnixTimeSeconds(unix); + + if (value is { ValueKind: JsonValueKind.String } && DateTimeOffset.TryParse(value.Value.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) + return timestamp; + + return null; + } + + internal static CodexSurface? IdentifySurface(string path) + { + try + { + foreach (var line in ReadSharedLines(path).Take(8)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (String(root, "type") != "session_meta" || Property(root, "payload") is not { } payload) + continue; + + if (!string.IsNullOrWhiteSpace(String(payload, "parent_thread_id"))) + return null; + + return String(payload, "originator")?.Contains("Desktop", StringComparison.OrdinalIgnoreCase) == true + ? CodexSurface.Desktop + : CodexSurface.Cli; + } + catch (JsonException) + { + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return null; + } + + private static IEnumerable ReadSharedLines(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + while (reader.ReadLine() is { } line) + yield return line; + } +} + +internal sealed class CodexStatusStore : IDisposable +{ + private const int ReloadDelayMilliseconds = 40; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + private readonly string _statusDirectory; + private readonly string _sessionsDirectory; + private readonly Func _processAlive; + private readonly Func _parseRollout; + private readonly Func _clock; + private readonly Func _desktopPresence; + private readonly object _workGate = new(); + private readonly object _publicationGate = new(); + private readonly object _scheduleGate = new(); + private readonly Timer _reloadTimer; + private readonly FileSystemWatcher? _statusWatcher; + private readonly FileSystemWatcher? _rolloutWatcher; + private readonly HashSet _pendingRolloutPaths = new(StringComparer.OrdinalIgnoreCase); + private CodexSnapshot? _desktopStatus; + private CodexSnapshot? _cliStatus; + private CodexSnapshot? _desktopRollout; + private CodexSnapshot? _cliRollout; + private CodexSnapshot? _desktopCandidate; + private CodexSnapshot? _cliCandidate; + private string? _desktopRolloutPath; + private string? _cliRolloutPath; + private DateTime _desktopRolloutWriteTime; + private DateTime _cliRolloutWriteTime; + private CodexSnapshot? _current; + private int _version; + private long _publicationGeneration; + private bool _pendingStatusReload; + private bool _pendingFullRescan; + private volatile bool _disposed; + + internal CodexSnapshot? Current + { + get => ReevaluateCurrent(); + } + + private readonly (CodexSnapshot? live, DateTimeOffset at, int version)[] _surfaceCache + = new (CodexSnapshot?, DateTimeOffset, int)[2]; + + internal CodexSnapshot? Candidate(CodexSurface surface) + { + var now = _clock(); + int version; CodexSnapshot? snap; + lock (_publicationGate) + { + version = _version; + snap = surface == CodexSurface.Desktop ? _desktopCandidate : _cliCandidate; + } + ref var c = ref _surfaceCache[(int)surface]; + if (c.version == version && now - c.at <= TimeSpan.FromSeconds(1)) + return c.live; + snap = surface == CodexSurface.Desktop + ? NormalizeDesktop(RefreshProcessAlive(snap), _desktopPresence(), now) + : RefreshProcessAlive(snap); + c = (IsActive(snap, now) ? snap : null, now, version); + return c.live; + } + + internal int Version + { + get + { + ReevaluateCurrent(); + lock (_publicationGate) return _version; + } + } + + internal CodexStatusStore() + : this( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "notch"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "sessions"), + IsProcessAlive, + watchFiles: true, + desktopPresence: () => CodexDesktopRuntime.Shared.Presence) + { + } + + internal CodexStatusStore( + string statusDirectory, + string sessionsDirectory, + Func processAlive, + bool watchFiles, + Func? parseRollout = null, + Func? clock = null, + Func? desktopPresence = null) + { + _statusDirectory = statusDirectory; + _sessionsDirectory = sessionsDirectory; + _processAlive = processAlive; + _parseRollout = parseRollout ?? CodexRollout.Parse; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + _desktopPresence = desktopPresence ?? (() => CodexDesktopRuntime.Shared.Presence); + Directory.CreateDirectory(_statusDirectory); + Directory.CreateDirectory(_sessionsDirectory); + _reloadTimer = new Timer(_ => ProcessScheduledReload(), null, Timeout.Infinite, Timeout.Infinite); + + if (watchFiles) + { + _statusWatcher = CreateWatcher(_statusDirectory, "*.json", includeSubdirectories: false, rollout: false); + _rolloutWatcher = CreateWatcher(_sessionsDirectory, "*.jsonl", includeSubdirectories: true, rollout: true); + } + + Reload(statusChanged: true, fullRescan: true, []); + } + + internal void ForceRefresh() => Reload(statusChanged: true, fullRescan: true, []); + + internal static CodexSnapshot? Select(CodexSnapshot? desktop, CodexSnapshot? cli, DateTimeOffset now) => + IsActive(desktop, now) ? desktop : IsActive(cli, now) ? cli : null; + + public void Dispose() + { + lock (_scheduleGate) + { + if (_disposed) + return; + + _disposed = true; + _statusWatcher?.Dispose(); + _rolloutWatcher?.Dispose(); + _reloadTimer.Dispose(); + } + + lock (_workGate) + { + } + } + + private FileSystemWatcher CreateWatcher(string directory, string filter, bool includeSubdirectories, bool rollout) + { + var watcher = new FileSystemWatcher(directory, filter) + { + IncludeSubdirectories = includeSubdirectories, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + if (rollout) + { + watcher.Changed += (_, e) => ScheduleReload(rolloutPath: e.FullPath); + watcher.Created += (_, e) => ScheduleReload(rolloutPath: e.FullPath); + watcher.Deleted += (_, _) => ScheduleReload(fullRescan: true); + watcher.Renamed += (_, _) => ScheduleReload(fullRescan: true); + } + else + { + watcher.Changed += (_, _) => ScheduleReload(statusChanged: true); + watcher.Created += (_, _) => ScheduleReload(statusChanged: true); + watcher.Deleted += (_, _) => ScheduleReload(statusChanged: true); + watcher.Renamed += (_, _) => ScheduleReload(statusChanged: true); + } + watcher.Error += HandleWatcherError; + watcher.EnableRaisingEvents = true; + return watcher; + } + + internal void HandleWatcherError(object? sender, ErrorEventArgs e) => + ScheduleReload(statusChanged: true, fullRescan: true); + + private void ScheduleReload(bool statusChanged = false, bool fullRescan = false, string? rolloutPath = null) + { + lock (_scheduleGate) + { + if (_disposed) + return; + + _pendingStatusReload |= statusChanged; + _pendingFullRescan |= fullRescan; + if (rolloutPath is not null) + _pendingRolloutPaths.Add(rolloutPath); + _reloadTimer.Change(ReloadDelayMilliseconds, Timeout.Infinite); + } + } + + private void ProcessScheduledReload() + { + bool statusChanged; + bool fullRescan; + string[] rolloutPaths; + lock (_scheduleGate) + { + if (_disposed) + return; + + statusChanged = _pendingStatusReload; + fullRescan = _pendingFullRescan; + rolloutPaths = [.. _pendingRolloutPaths]; + _pendingStatusReload = false; + _pendingFullRescan = false; + _pendingRolloutPaths.Clear(); + } + + Reload(statusChanged, fullRescan, rolloutPaths); + } + + private void Reload(bool statusChanged, bool fullRescan, IReadOnlyCollection rolloutPaths) + { + lock (_workGate) + { + if (_disposed) + return; + + if (statusChanged || fullRescan) + { + ApplyStatusRead(ref _desktopStatus, ReadStatusWithRetry( + Path.Combine(_statusDirectory, "desktop.json"), CodexSurface.Desktop, _desktopStatus is not null)); + ApplyStatusRead(ref _cliStatus, ReadStatusWithRetry( + Path.Combine(_statusDirectory, "cli.json"), CodexSurface.Cli, _cliStatus is not null)); + } + else + { + _desktopStatus = RefreshProcessAlive(_desktopStatus); + _cliStatus = RefreshProcessAlive(_cliStatus); + } + + if (fullRescan) + ScanRollouts(); + else if (rolloutPaths.Count > 0) + ProcessRolloutChanges(rolloutPaths); + + if (_disposed) + return; + + var now = _clock(); + var desktopCandidate = NormalizeDesktop(Merge(_desktopStatus, _desktopRollout, now), _desktopPresence(), now); + var cliCandidate = Merge(_cliStatus, _cliRollout, now); + var next = Select(desktopCandidate, cliCandidate, now); + lock (_publicationGate) + { + if (_disposed) + return; + _desktopCandidate = desktopCandidate; + _cliCandidate = cliCandidate; + _current = next; + _version++; + _publicationGeneration++; + } + } + } + + private CodexSnapshot? ReevaluateCurrent() + { + while (true) + { + CodexSnapshot? desktop; + CodexSnapshot? cli; + long generation; + lock (_publicationGate) + { + desktop = _desktopCandidate; + cli = _cliCandidate; + generation = _publicationGeneration; + } + + var now = _clock(); + desktop = NormalizeDesktop(RefreshProcessAlive(desktop), _desktopPresence(), now); + cli = RefreshProcessAlive(cli); + var next = Select(desktop, cli, now); + + lock (_publicationGate) + { + if (generation != _publicationGeneration) + continue; + + if (!Equals(_current, next)) + { + _current = next; + _version++; + } + return _current; + } + } + } + + private StatusRead ReadStatusWithRetry(string path, CodexSurface source, bool hasExisting) + { + var read = ReadStatus(path, source); + if (read.Kind == StatusReadKind.Transient || read.Kind == StatusReadKind.Missing && hasExisting) + { + Thread.Sleep(ReloadDelayMilliseconds); + read = ReadStatus(read.Path, read.Source); + } + return read; + } + + private static void ApplyStatusRead(ref CodexSnapshot? target, StatusRead read) + { + if (read.Kind == StatusReadKind.Success) + target = read.Snapshot; + else if (read.Kind == StatusReadKind.Missing) + target = null; + } + + private CodexSnapshot? RefreshProcessAlive(CodexSnapshot? snapshot) => + snapshot is null ? null : snapshot with { ProcessAlive = ProcessAlive(snapshot.Pid) }; + + private static bool IsActive(CodexSnapshot? snapshot, DateTimeOffset now) => + snapshot is not null && snapshot.State != "ended" && + (snapshot.ProcessAlive || snapshot.UpdatedAt >= now.AddSeconds(-30)); + + private static bool IsFresh(CodexSnapshot snapshot, DateTimeOffset now) => + snapshot.ProcessAlive || snapshot.UpdatedAt >= now.AddSeconds(-30); + + internal static CodexSnapshot? NormalizeDesktop( + CodexSnapshot? snapshot, CodexDesktopPresence presence, DateTimeOffset now) + { + if (!presence.Running) + return null; + if (snapshot is null || snapshot.UpdatedAt < presence.StartedAt) + return EmptyDesktop(presence.StartedAt); + if (snapshot.UpdatedAt < now.AddSeconds(-30) && + snapshot.State is "working" or "waiting_input" or "compacting") + return snapshot with { State = "idle", CurrentTool = null, StartedAt = null, ProcessAlive = true }; + return snapshot with { ProcessAlive = true }; + } + + private static CodexSnapshot EmptyDesktop(DateTimeOffset startedAt) => new( + CodexSurface.Desktop, "idle", null, null, null, null, null, 0, 0, 0, 0, 0, + null, null, startedAt, true); + + private static CodexSnapshot? Merge(CodexSnapshot? hook, CodexSnapshot? rollout, DateTimeOffset now) + { + if (hook is null) + return rollout; + if (rollout is null) + return hook; + + var lifecycle = IsFresh(hook, now) ? hook : rollout; + var fields = rollout.PresentFields; + return lifecycle with + { + Source = hook.Source, + Cwd = lifecycle.Cwd ?? hook.Cwd ?? rollout.Cwd, + Pid = hook.Pid, + ConsolePid = hook.ConsolePid, + ContextUsed = fields.HasFlag(CodexSnapshotFields.ContextUsed) ? rollout.ContextUsed : hook.ContextUsed, + ContextMax = fields.HasFlag(CodexSnapshotFields.ContextMax) ? rollout.ContextMax : hook.ContextMax, + PromptTokens = fields.HasFlag(CodexSnapshotFields.PromptTokens) ? rollout.PromptTokens : hook.PromptTokens, + PrimaryLimit = fields.HasFlag(CodexSnapshotFields.PrimaryLimit) ? rollout.PrimaryLimit : hook.PrimaryLimit, + SecondaryLimit = fields.HasFlag(CodexSnapshotFields.SecondaryLimit) ? rollout.SecondaryLimit : hook.SecondaryLimit, + UpdatedAt = hook.UpdatedAt > rollout.UpdatedAt ? hook.UpdatedAt : rollout.UpdatedAt, + ProcessAlive = hook.ProcessAlive, + PresentFields = hook.PresentFields | rollout.PresentFields, + }; + } + + private StatusRead ReadStatus(string path, CodexSurface source) + { + try + { + if (!File.Exists(path)) + return new StatusRead(path, source, StatusReadKind.Missing, null); + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var status = JsonSerializer.Deserialize(stream, JsonOptions); + if (status is null) + return new StatusRead(path, source, StatusReadKind.Transient, null); + + var updatedAt = status.UpdatedAt ?? File.GetLastWriteTimeUtc(path); + var snapshot = new CodexSnapshot( + source, status.State ?? "idle", status.CurrentTool, status.StartedAt, status.CompactedAt, + status.Message, status.Cwd, status.Pid, status.ConsolePid, status.ContextUsed, status.ContextMax, + status.PromptTokens, status.PrimaryLimit, status.SecondaryLimit, updatedAt, + ProcessAlive(status.Pid)); + return new StatusRead(path, source, StatusReadKind.Success, snapshot); + } + catch (IOException) + { + return new StatusRead(path, source, StatusReadKind.Transient, null); + } + catch (JsonException) + { + return new StatusRead(path, source, StatusReadKind.Transient, null); + } + catch (UnauthorizedAccessException) + { + return new StatusRead(path, source, StatusReadKind.Transient, null); + } + } + + private bool ProcessAlive(int pid) + { + try + { + return pid > 0 && _processAlive(pid); + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private void ScanRollouts() + { + RolloutCandidate? desktop = null; + RolloutCandidate? cli = null; + + try + { + foreach (var path in Directory.EnumerateFiles(_sessionsDirectory, "*.jsonl", SearchOption.AllDirectories)) + { + var source = CodexRollout.IdentifySurface(path); + if (source is null) + continue; + + var candidate = new RolloutCandidate(path, File.GetLastWriteTimeUtc(path)); + if (source == CodexSurface.Desktop && (desktop is null || candidate.WriteTime > desktop.Value.WriteTime)) + desktop = candidate; + else if (source == CodexSurface.Cli && (cli is null || candidate.WriteTime > cli.Value.WriteTime)) + cli = candidate; + } + + ApplyRolloutCandidate(CodexSurface.Desktop, desktop); + ApplyRolloutCandidate(CodexSurface.Cli, cli); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private void ApplyRolloutCandidate(CodexSurface source, RolloutCandidate? candidate) + { + if (candidate is null) + { + SetRollout(source, null, null, DateTime.MinValue); + return; + } + + var snapshot = _parseRollout(candidate.Value.Path); + if (snapshot is not null) + SetRollout(source, snapshot, candidate.Value.Path, candidate.Value.WriteTime); + } + + private void ProcessRolloutChanges(IEnumerable paths) + { + foreach (var path in paths.Distinct(StringComparer.OrdinalIgnoreCase)) + { + try + { + if (!File.Exists(path) || CodexRollout.IdentifySurface(path) is not { } source) + continue; + + var writeTime = File.GetLastWriteTimeUtc(path); + var currentPath = source == CodexSurface.Desktop ? _desktopRolloutPath : _cliRolloutPath; + var currentWriteTime = source == CodexSurface.Desktop ? _desktopRolloutWriteTime : _cliRolloutWriteTime; + if (!string.Equals(path, currentPath, StringComparison.OrdinalIgnoreCase) && writeTime < currentWriteTime) + continue; + + var snapshot = _parseRollout(path); + if (snapshot is not null) + SetRollout(source, snapshot, path, writeTime); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + private void SetRollout(CodexSurface source, CodexSnapshot? snapshot, string? path, DateTime writeTime) + { + if (source == CodexSurface.Desktop) + { + _desktopRollout = snapshot; + _desktopRolloutPath = path; + _desktopRolloutWriteTime = writeTime; + } + else + { + _cliRollout = snapshot; + _cliRolloutPath = path; + _cliRolloutWriteTime = writeTime; + } + } + + private static bool IsProcessAlive(int pid) + { + if (pid <= 0) + return false; + + try + { + using var process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private sealed class HookStatus + { + public string? State { get; set; } + public string? CurrentTool { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? CompactedAt { get; set; } + public string? Message { get; set; } + public string? Cwd { get; set; } + public int Pid { get; set; } + public int ConsolePid { get; set; } + public long ContextUsed { get; set; } + public long ContextMax { get; set; } + public long PromptTokens { get; set; } + public CodexLimit? PrimaryLimit { get; set; } + public CodexLimit? SecondaryLimit { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + } + + private enum StatusReadKind { Missing, Success, Transient } + + private readonly record struct StatusRead( + string Path, + CodexSurface Source, + StatusReadKind Kind, + CodexSnapshot? Snapshot); + + private readonly record struct RolloutCandidate(string Path, DateTime WriteTime); +} diff --git a/src/Halo.App/Halo.App.csproj b/src/Halo.App/Halo.App.csproj new file mode 100644 index 0000000..48af95b --- /dev/null +++ b/src/Halo.App/Halo.App.csproj @@ -0,0 +1,22 @@ + + + WinExe + net9.0-windows10.0.19041.0 + 3.2.0 + win-x64 + enable + latest + true + Halo + app.manifest + Assets\halo.ico + + + + + + + + + + diff --git a/src/Halo.App/Interop/Clipboard.cs b/src/Halo.App/Interop/Clipboard.cs new file mode 100644 index 0000000..ef8e4b9 --- /dev/null +++ b/src/Halo.App/Interop/Clipboard.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.InteropServices; + +namespace Halo.Interop; + +internal static class Clipboard +{ + public static bool SetText(string text) + { + if (string.IsNullOrEmpty(text)) return false; + if (!Win32.OpenClipboard(IntPtr.Zero)) return false; + try + { + Win32.EmptyClipboard(); + int bytes = (text.Length + 1) * 2; + IntPtr hGlobal = Win32.GlobalAlloc(Win32.GMEM_MOVEABLE, (UIntPtr)bytes); + if (hGlobal == IntPtr.Zero) return false; + IntPtr target = Win32.GlobalLock(hGlobal); + if (target == IntPtr.Zero) return false; + try { Marshal.Copy((text + '\0').ToCharArray(), 0, target, text.Length + 1); } + finally { Win32.GlobalUnlock(hGlobal); } + + return Win32.SetClipboardData(Win32.CF_UNICODETEXT, hGlobal) != IntPtr.Zero; + } + finally { Win32.CloseClipboard(); } + } + + public static string? Text() + { + if (!Win32.IsClipboardFormatAvailable(Win32.CF_UNICODETEXT)) return null; + if (!Win32.OpenClipboard(IntPtr.Zero)) return null; + try + { + IntPtr h = Win32.GetClipboardData(Win32.CF_UNICODETEXT); + if (h == IntPtr.Zero) return null; + IntPtr p = Win32.GlobalLock(h); + if (p == IntPtr.Zero) return null; + try { return Marshal.PtrToStringUni(p); } + finally { Win32.GlobalUnlock(h); } + } + catch { return null; } + finally { Win32.CloseClipboard(); } + } +} diff --git a/src/Halo.App/Interop/ConsoleRead.cs b/src/Halo.App/Interop/ConsoleRead.cs new file mode 100644 index 0000000..0cf222b --- /dev/null +++ b/src/Halo.App/Interop/ConsoleRead.cs @@ -0,0 +1,170 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Halo.Interop; + +internal static class ConsoleRead +{ + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(uint pid); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr h); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetConsoleCtrlHandler(IntPtr handler, bool add); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetConsoleScreenBufferInfo(IntPtr h, out CONSOLE_SCREEN_BUFFER_INFO info); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool ReadConsoleOutputCharacterW(IntPtr h, [Out] char[] buf, uint len, + COORD at, out uint read); + + [StructLayout(LayoutKind.Sequential)] + private struct COORD { public short X, Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct SMALL_RECT { public short Left, Top, Right, Bottom; } + + [StructLayout(LayoutKind.Sequential)] + private struct CONSOLE_SCREEN_BUFFER_INFO + { + public COORD Size, CursorPosition; + public ushort Attributes; + public SMALL_RECT Window; + public COORD MaximumWindowSize; + } + + private const uint GENERIC_READ = 0x80000000, GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3; + private static readonly IntPtr Invalid = new(-1); + + internal static string[]? Tail(int pid, int lines = 6, int below = 0) + { + if (pid <= 0) return null; + bool attached = false; + IntPtr h = Invalid; + try + { + + FreeConsole(); + if (!AttachConsole((uint)pid)) return null; + attached = true; + + SetConsoleCtrlHandler(IntPtr.Zero, true); + h = CreateFileW("CONOUT$", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); + if (h == Invalid) return null; + if (!GetConsoleScreenBufferInfo(h, out var info)) return null; + + int width = info.Size.X; + + int bottom = Math.Min(info.CursorPosition.Y + 1 + below, info.Size.Y); + int top = Math.Max(0, bottom - below - lines); + if (width <= 0 || bottom <= top) return null; + + var rows = new string[bottom - top]; + var buf = new char[width]; + for (int y = top; y < bottom; y++) + { + if (!ReadConsoleOutputCharacterW(h, buf, (uint)width, new COORD { X = 0, Y = (short)y }, + out uint read)) + return null; + rows[y - top] = new string(buf, 0, (int)Math.Min(read, (uint)width)).TrimEnd(); + } + return rows; + } + catch { return null; } + finally + { + try { if (h != Invalid) CloseHandle(h); } catch { } + try { if (attached) FreeConsole(); } catch { } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT_RECORD + { + public ushort EventType; + public int KeyDown; + public ushort RepeatCount; + public ushort VirtualKeyCode; + public ushort VirtualScanCode; + public ushort UnicodeChar; + public uint ControlKeyState; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool WriteConsoleInputW(IntPtr h, INPUT_RECORD[] buf, uint len, out uint written); + + private const ushort KEY_EVENT = 1; + + internal const ushort VkDown = 0x28, VkUp = 0x26, VkEnter = 0x0D, VkTab = 0x09, VkEscape = 0x1B; + + internal static bool Press(int pid, ushort vk, int times = 1) + => Send(pid, null, vk, times); + + internal static bool Type(int pid, string text) + => Send(pid, text, 0, 1); + + private static bool Send(int pid, string? text, ushort vk, int times) + { + if (pid <= 0 || (string.IsNullOrEmpty(text) && vk == 0)) return false; + bool attached = false; + IntPtr h = Invalid; + try + { + FreeConsole(); + if (!AttachConsole((uint)pid)) return false; + attached = true; + SetConsoleCtrlHandler(IntPtr.Zero, true); + h = CreateFileW("CONIN$", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); + if (h == Invalid) return false; + + int count = text is null ? times : text.Length; + var recs = new INPUT_RECORD[count * 2]; + for (int i = 0; i < count; i++) + { + recs[i * 2] = new INPUT_RECORD + { + EventType = KEY_EVENT, KeyDown = 1, RepeatCount = 1, + UnicodeChar = text is null ? (ushort)0 : text[i], + VirtualKeyCode = vk, + }; + recs[i * 2 + 1] = recs[i * 2]; + recs[i * 2 + 1].KeyDown = 0; + } + return WriteConsoleInputW(h, recs, (uint)recs.Length, out uint wrote) && wrote > 0; + } + catch { return false; } + finally + { + try { if (h != Invalid) CloseHandle(h); } catch { } + try { if (attached) FreeConsole(); } catch { } + } + } + + internal static string Dump(int pid, int lines = 12, int below = 0) + { + var rows = Tail(pid, lines, below); + return rows is null ? "(no console)" : string.Join("\n", rows); + } + + internal static string Describe(int pid, int lines = 14, int below = 0) + { + var sb = new StringBuilder(); + sb.AppendLine($"pid {pid}"); + sb.AppendLine(Dump(pid, lines, below)); + return sb.ToString(); + } +} diff --git a/src/Halo.App/Interop/Dispatcher.cs b/src/Halo.App/Interop/Dispatcher.cs new file mode 100644 index 0000000..347c635 --- /dev/null +++ b/src/Halo.App/Interop/Dispatcher.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.InteropServices; + +namespace Halo.Interop; + +internal static class Dispatcher +{ + [StructLayout(LayoutKind.Sequential)] + private struct Options + { + public int dwSize; + public int threadType; + public int apartmentType; + } + + [DllImport("CoreMessaging.dll")] + private static extern int CreateDispatcherQueueController(Options options, + [MarshalAs(UnmanagedType.IUnknown)] out object controller); + + private static object? _controller; + + public static void Ensure() + { + if (_controller != null) return; + var o = new Options { dwSize = Marshal.SizeOf(), threadType = 2, apartmentType = 2 }; + int hr = CreateDispatcherQueueController(o, out _controller); + if (hr < 0) + throw new InvalidOperationException($"CreateDispatcherQueueController failed 0x{hr:X8}"); + } +} diff --git a/src/Halo.App/Interop/FileDrag.cs b/src/Halo.App/Interop/FileDrag.cs new file mode 100644 index 0000000..be14771 --- /dev/null +++ b/src/Halo.App/Interop/FileDrag.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ComTypes = System.Runtime.InteropServices.ComTypes; + +namespace Halo.Interop; + +internal static class FileDrag +{ + + public static volatile bool Dragging; + + private static readonly Guid BHID_DataObject = new("B8C0BD9F-ED24-455C-83E6-D5390C4FE8C4"); + private static readonly Guid IID_IDataObject = new("0000010e-0000-0000-C000-000000000046"); + + private const int DRAGDROP_S_DROP = 0x00040100; + + public static bool Out(string path) => Out(new[] { path }); + + public static bool Out(string[] paths) + { + if (paths == null || paths.Length == 0) return false; + var pidls = new List(paths.Length); + try + { + foreach (var p in paths) + if (Win32.SHParseDisplayName(p, IntPtr.Zero, out var pidl, 0, out _) == 0 && pidl != IntPtr.Zero) + pidls.Add(pidl); + if (pidls.Count == 0) return false; + + if (Win32.SHCreateShellItemArrayFromIDLists((uint)pidls.Count, pidls.ToArray(), out var arr) != 0 || arr == null) + return false; + try + { + if (arr.BindToHandler(IntPtr.Zero, BHID_DataObject, IID_IDataObject, out var pdo) != 0 + || pdo is not ComTypes.IDataObject data) + return false; + Dragging = true; + int hr; + try { hr = Win32.SHDoDragDrop(IntPtr.Zero, data, new DropSource(), Win32.DROPEFFECT_COPY | Win32.DROPEFFECT_MOVE, out _); } + finally { Dragging = false; } + + return hr == DRAGDROP_S_DROP; + } + finally { Marshal.ReleaseComObject(arr); } + } + catch { Dragging = false; return false; } + finally { foreach (var pidl in pidls) Win32.ILFree(pidl); } + } + + private sealed class DropSource : Win32.IDropSource + { + private const int S_OK = 0, DRAGDROP_S_DROP = 0x00040100, DRAGDROP_S_CANCEL = 0x00040101, + DRAGDROP_S_USEDEFAULTCURSORS = 0x00040102, MK_LBUTTON = 0x0001; + + public int QueryContinueDrag(int escapePressed, int keyState) + { + if (escapePressed != 0) return DRAGDROP_S_CANCEL; + if ((keyState & MK_LBUTTON) == 0) return DRAGDROP_S_DROP; + return S_OK; + } + + public int GiveFeedback(int effect) => DRAGDROP_S_USEDEFAULTCURSORS; + } +} diff --git a/src/Halo.App/Interop/FileDropTarget.cs b/src/Halo.App/Interop/FileDropTarget.cs new file mode 100644 index 0000000..eccd495 --- /dev/null +++ b/src/Halo.App/Interop/FileDropTarget.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using Halo.Widgets; + +namespace Halo.Interop; + +internal sealed class FileDropTarget : Win32.IDropTarget +{ + private const int S_OK = 0; + + public int DragEnter(IDataObject data, int keyState, Win32.POINTL pt, ref int effect) + { + if (FileDrag.Dragging) { effect = Win32.DROPEFFECT_NONE; return S_OK; } + bool files = HasHdrop(data); + FileTray.SetDragActive(files); + effect = files ? Win32.DROPEFFECT_COPY : Win32.DROPEFFECT_NONE; + return S_OK; + } + + public int DragOver(int keyState, Win32.POINTL pt, ref int effect) + { + effect = FileTray.DragActive ? Win32.DROPEFFECT_COPY : Win32.DROPEFFECT_NONE; + return S_OK; + } + + public int DragLeave() + { + FileTray.SetDragActive(false); + return S_OK; + } + + public int Drop(IDataObject data, int keyState, Win32.POINTL pt, ref int effect) + { + foreach (var p in GetPaths(data)) FileTray.Add(p); + FileTray.SetDragActive(false); + effect = Win32.DROPEFFECT_COPY; + return S_OK; + } + + private static FORMATETC HdropFormat() => new() + { + cfFormat = Win32.CF_HDROP, + dwAspect = DVASPECT.DVASPECT_CONTENT, + lindex = -1, + tymed = TYMED.TYMED_HGLOBAL, + }; + + private static bool HasHdrop(IDataObject data) + { + try { var f = HdropFormat(); return data.QueryGetData(ref f) == S_OK; } + catch { return false; } + } + + private static string[] GetPaths(IDataObject data) + { + var f = HdropFormat(); + STGMEDIUM m = default; + try + { + data.GetData(ref f, out m); + if (m.unionmember == IntPtr.Zero) return Array.Empty(); + uint n = Win32.DragQueryFile(m.unionmember, 0xFFFFFFFF, null, 0); + var list = new List((int)n); + var sb = new StringBuilder(1024); + for (uint i = 0; i < n; i++) + { + sb.Clear(); + if (Win32.DragQueryFile(m.unionmember, i, sb, (uint)sb.Capacity) > 0) + list.Add(sb.ToString()); + } + return list.ToArray(); + } + catch { return Array.Empty(); } + finally { Win32.ReleaseStgMedium(ref m); } + } +} diff --git a/src/Halo.App/Interop/KeyGrab.cs b/src/Halo.App/Interop/KeyGrab.cs new file mode 100644 index 0000000..e56d6b0 --- /dev/null +++ b/src/Halo.App/Interop/KeyGrab.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Halo.Interop; + +internal sealed class KeyGrab +{ + private readonly HashSet _eaten = []; + private Win32.HookProc? _proc; + private IntPtr _hook; + + internal Action? OnChar; + internal Action? OnKey; + + internal bool Active => _hook != IntPtr.Zero; + + internal void Start() + { + if (_hook != IntPtr.Zero) return; + try + { + _proc = Hook; + _hook = Win32.SetWindowsHookExW(Win32.WH_KEYBOARD_LL, _proc, IntPtr.Zero, 0); + if (_hook == IntPtr.Zero) _proc = null; + } + catch { _proc = null; _hook = IntPtr.Zero; } + } + + internal void Stop() + { + if (_hook == IntPtr.Zero) return; + try { Win32.UnhookWindowsHookEx(_hook); } catch { } + _hook = IntPtr.Zero; + _proc = null; + _eaten.Clear(); + } + + private IntPtr Hook(int code, IntPtr wParam, IntPtr lParam) + { + try + { + if (code == 0) + { + uint msg = (uint)wParam; + var info = Marshal.PtrToStructure(lParam); + if (msg is Win32.WM_KEYDOWN or Win32.WM_SYSKEYDOWN) + { + if (Consume(info.vkCode, info.scanCode)) { _eaten.Add(info.vkCode); return new IntPtr(1); } + _eaten.Remove(info.vkCode); + } + else if (msg is Win32.WM_KEYUP or Win32.WM_SYSKEYUP && _eaten.Remove(info.vkCode)) + return new IntPtr(1); + } + } + catch { } + return Win32.CallNextHookEx(IntPtr.Zero, code, wParam, lParam); + } + + private bool Consume(uint vk, uint scan) + { + bool alt = Down(Win32.VK_MENU) || Down(Win32.VK_LWIN) || Down(Win32.VK_RWIN); + if (alt) return false; + bool ctrl = Down(Win32.VK_CONTROL); + + if (vk is Win32.VK_BACK or Win32.VK_RETURN or Win32.VK_ESCAPE + || (ctrl && vk == Win32.VK_V)) + { + OnKey?.Invoke((int)vk); + return true; + } + if (ctrl) return false; + + string text = Translate(vk, scan); + if (text.Length == 0) return false; + bool any = false; + foreach (char c in text) + if (c >= ' ' && c != (char)0x7F) { OnChar?.Invoke(c); any = true; } + return any; + } + + private static bool Down(int vk) => (Win32.GetAsyncKeyState(vk) & 0x8000) != 0; + + private static string Translate(uint vk, uint scan) + { + try + { + var state = new byte[256]; + if (Down(Win32.VK_SHIFT)) state[Win32.VK_SHIFT] = 0x80; + if ((Win32.GetKeyState(Win32.VK_CAPITAL) & 1) != 0) state[Win32.VK_CAPITAL] = 1; + + IntPtr layout = IntPtr.Zero; + var fg = Win32.GetForegroundWindow(); + if (fg != IntPtr.Zero) layout = Win32.GetKeyboardLayout(Win32.GetWindowThreadProcessId(fg, out _)); + + const int cap = 8; + var buf = new byte[cap * 2]; + + int n = Win32.ToUnicodeEx(vk, scan, state, buf, cap, 4, layout); + return n > 0 ? System.Text.Encoding.Unicode.GetString(buf, 0, Math.Min(n, cap) * 2) : ""; + } + catch { return ""; } + } +} diff --git a/src/Halo.App/Interop/Win32.cs b/src/Halo.App/Interop/Win32.cs new file mode 100644 index 0000000..f060dc9 --- /dev/null +++ b/src/Halo.App/Interop/Win32.cs @@ -0,0 +1,599 @@ +using System; +using System.Runtime.InteropServices; + +namespace Halo.Interop; + +internal static class Win32 +{ + public const int WS_POPUP = unchecked((int)0x80000000); + public const int WS_EX_LAYERED = 0x00080000; + public const int WS_EX_TOOLWINDOW = 0x00000080; + public const int WS_EX_TOPMOST = 0x00000008; + public const int WS_EX_NOREDIRECTIONBITMAP = 0x00200000; + public const int SW_SHOWNOACTIVATE = 4; + public const int SW_HIDE = 0; + + public const uint WM_DESTROY = 0x0002; + public const uint WM_DISPLAYCHANGE = 0x007E; + public const uint WM_SETTINGCHANGE = 0x001A; + public const uint WM_TIMECHANGE = 0x001E; + public const uint WM_MOUSEMOVE = 0x0200; + public const uint WM_MOUSELEAVE = 0x02A3; + public const uint WM_NCHITTEST = 0x0084; + public const int HTTRANSPARENT = -1; + public const int HTCLIENT = 1; + + public const uint SPI_GETWORKAREA = 0x0030; + public const int TME_LEAVE = 0x00000002; + + [StructLayout(LayoutKind.Sequential)] + public struct TRACKMOUSEEVENT + { + public int cbSize; + public int dwFlags; + public IntPtr hwndTrack; + public int dwHoverTime; + } + + [DllImport("user32.dll")] + public static extern bool TrackMouseEvent(ref TRACKMOUSEEVENT lpEventTrack); + + public delegate IntPtr WndProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct WNDCLASSEX + { + public int cbSize; + public int style; + [MarshalAs(UnmanagedType.FunctionPtr)] public WndProc lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + public string? lpszMenuName; + public string lpszClassName; + public IntPtr hIconSm; + } + + [StructLayout(LayoutKind.Sequential)] + public struct MSG + { + public IntPtr hwnd; + public uint message; + public IntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; + } + + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int left; + public int top; + public int right; + public int bottom; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern ushort RegisterClassEx(ref WNDCLASSEX lpwcx); + + public static readonly IntPtr IDC_ARROW = new(32512); + public static readonly IntPtr IDC_HAND = new(32649); + public const uint WM_SETCURSOR = 0x0020; + [DllImport("user32.dll")] + public static extern IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName); + + [DllImport("user32.dll")] + public static extern IntPtr SetCursor(IntPtr hCursor); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr CreateWindowEx(int exStyle, string className, string windowName, int style, + int x, int y, int w, int h, IntPtr parent, IntPtr menu, IntPtr hInstance, IntPtr param); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool DestroyWindow(IntPtr hwnd); + + public static readonly IntPtr HWND_MESSAGE = new(-3); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct NOTIFYICONDATA + { + public int cbSize; + public IntPtr hWnd; + public int uID; + public int uFlags; + public int uCallbackMessage; + public IntPtr hIcon; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip; + public int dwState; + public int dwStateMask; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo; + public int uVersion; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle; + public int dwInfoFlags; + public Guid guidItem; + public IntPtr hBalloonIcon; + } + + public const int NIM_ADD = 0, NIM_MODIFY = 1, NIM_DELETE = 2, NIM_SETVERSION = 4; + public const int NIF_MESSAGE = 0x01, NIF_ICON = 0x02, NIF_TIP = 0x04, NIF_SHOWTIP = 0x80; + public const int NOTIFYICON_VERSION_4 = 4; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + public static extern bool Shell_NotifyIcon(int message, ref NOTIFYICONDATA data); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern uint RegisterWindowMessage(string message); + + [DllImport("user32.dll")] + public static extern IntPtr CreatePopupMenu(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern bool AppendMenu(IntPtr menu, int flags, int id, string? item); + + [DllImport("user32.dll")] + public static extern bool DestroyMenu(IntPtr menu); + + public const int MF_STRING = 0x0000, MF_SEPARATOR = 0x0800; + public const int TPM_RIGHTBUTTON = 0x0002, TPM_RETURNCMD = 0x0100; + + [DllImport("user32.dll")] + public static extern int TrackPopupMenuEx(IntPtr menu, int flags, int x, int y, IntPtr hwnd, IntPtr lptpm); + + [DllImport("user32.dll")] + public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern IntPtr DefWindowProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hwnd, int cmd); + + public const uint WDA_EXCLUDEFROMCAPTURE = 0x11; + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity); + + public const int SM_CXSCREEN = 0, SM_CYSCREEN = 1; + + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int index); + + [DllImport("user32.dll")] + public static extern int GetMessage(out MSG msg, IntPtr hwnd, uint min, uint max); + + [DllImport("user32.dll")] + public static extern bool TranslateMessage(ref MSG msg); + + [DllImport("user32.dll")] + public static extern IntPtr DispatchMessage(ref MSG msg); + + [DllImport("user32.dll")] + public static extern void PostQuitMessage(int code); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SystemParametersInfo(uint action, uint uiParam, ref RECT pvParam, uint winIni); + + [DllImport("kernel32.dll")] + public static extern IntPtr GetModuleHandle(string? name); + + public const int WCA_ACCENT_POLICY = 19; + public const int ACCENT_ENABLE_ACRYLICBLURBEHIND = 4; + + [StructLayout(LayoutKind.Sequential)] + public struct AccentPolicy + { + public int AccentState; + public int AccentFlags; + public uint GradientColor; + public int AnimationId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct WindowCompositionAttributeData + { + public int Attribute; + public IntPtr Data; + public int SizeOfData; + } + + [DllImport("user32.dll")] + public static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + + [DllImport("gdi32.dll")] + public static extern IntPtr CreateRoundRectRgn(int left, int top, int right, int bottom, int widthEllipse, int heightEllipse); + + [DllImport("gdi32.dll")] + public static extern IntPtr CreateRectRgn(int left, int top, int right, int bottom); + + [DllImport("gdi32.dll")] + public static extern int CombineRgn(IntPtr dst, IntPtr src1, IntPtr src2, int mode); + + [DllImport("gdi32.dll")] + public static extern bool DeleteObject(IntPtr obj); + + public const int RGN_OR = 2; + + [DllImport("user32.dll")] + public static extern int SetWindowRgn(IntPtr hwnd, IntPtr hRgn, bool redraw); + + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public const uint SWP_NOSIZE = 0x0001; + public const uint SWP_NOMOVE = 0x0002; + public const uint SWP_NOACTIVATE = 0x0010; + + public const int WS_EX_NOACTIVATE = 0x08000000; + public const uint ULW_ALPHA = 0x00000002; + public const byte AC_SRC_OVER = 0x00; + public const byte AC_SRC_ALPHA = 0x01; + + [StructLayout(LayoutKind.Sequential)] + public struct SIZE + { + public int cx; + public int cy; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct BLENDFUNCTION + { + public byte BlendOp; + public byte BlendFlags; + public byte SourceConstantAlpha; + public byte AlphaFormat; + } + + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetWindowDC(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); + + public const uint SRCCOPY = 0x00CC0020; + + [DllImport("gdi32.dll")] + public static extern bool BitBlt(IntPtr hdc, int x, int y, int w, int h, IntPtr src, int sx, int sy, uint rop); + + [DllImport("gdi32.dll")] + public static extern IntPtr CreateCompatibleDC(IntPtr hdc); + + [DllImport("gdi32.dll")] + public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj); + + [DllImport("gdi32.dll")] + public static extern bool DeleteDC(IntPtr hdc); + + [DllImport("user32.dll")] + public static extern bool UpdateLayeredWindow(IntPtr hwnd, IntPtr hdcDst, ref POINT pptDst, ref SIZE psize, + IntPtr hdcSrc, ref POINT pptSrc, uint crKey, ref BLENDFUNCTION pblend, uint dwFlags); + + [StructLayout(LayoutKind.Sequential)] + public struct BITMAPINFOHEADER + { + public int biSize; + public int biWidth; + public int biHeight; + public short biPlanes; + public short biBitCount; + public int biCompression; + public int biSizeImage; + public int biXPelsPerMeter; + public int biYPelsPerMeter; + public int biClrUsed; + public int biClrImportant; + } + + [DllImport("gdi32.dll")] + public static extern IntPtr CreateDIBSection(IntPtr hdc, ref BITMAPINFOHEADER bmi, uint usage, out IntPtr bits, IntPtr section, uint offset); + + [DllImport("user32.dll")] + public static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags); + + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT p); + + public const int VK_LBUTTON = 0x01; + public const int VK_ESCAPE = 0x1B; + public const int VK_CONTROL = 0x11; + public const int VK_BACK = 0x08; + public const int VK_RETURN = 0x0D; + public const int VK_V = 0x56; + + [DllImport("user32.dll")] + public static extern short GetAsyncKeyState(int vKey); + + public const uint WM_KEYDOWN = 0x0100; + public const uint WM_CHAR = 0x0102; + public const int GWL_EXSTYLE = -20; + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + public static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int index); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + public static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value); + + public const uint WM_CLIPBOARDUPDATE = 0x031D; + public const uint CF_BITMAP = 2; + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool AddClipboardFormatListener(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll")] + public static extern uint GetClipboardSequenceNumber(); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool OpenClipboard(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern bool CloseClipboard(); + + [DllImport("user32.dll")] + public static extern IntPtr GetClipboardData(uint format); + + public const uint CF_UNICODETEXT = 13; + public const uint GMEM_MOVEABLE = 0x0002; + + [DllImport("user32.dll")] + public static extern bool EmptyClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SetClipboardData(uint format, IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GlobalAlloc(uint flags, UIntPtr bytes); + + [DllImport("kernel32.dll")] + public static extern IntPtr GlobalLock(IntPtr hMem); + + [DllImport("kernel32.dll")] + public static extern bool GlobalUnlock(IntPtr hMem); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] + public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach); + [DllImport("user32.dll")] + public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); + [DllImport("kernel32.dll")] + public static extern uint GetCurrentThreadId(); + public const uint KEYEVENTF_KEYUP = 0x0002; + + public const int WH_KEYBOARD_LL = 13; + public const int VK_SHIFT = 0x10, VK_MENU = 0x12, VK_CAPITAL = 0x14; + public const int VK_LWIN = 0x5B, VK_RWIN = 0x5C; + public const uint WM_SYSKEYDOWN = 0x0104, WM_KEYUP = 0x0101, WM_SYSKEYUP = 0x0105; + + [StructLayout(LayoutKind.Sequential)] + public struct KBDLLHOOKSTRUCT + { + public uint vkCode, scanCode, flags, time; + public UIntPtr dwExtraInfo; + } + + public delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SetWindowsHookExW(int idHook, HookProc fn, IntPtr mod, uint threadId); + + [DllImport("user32.dll")] + public static extern bool UnhookWindowsHookEx(IntPtr hook); + + [DllImport("user32.dll")] + public static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern short GetKeyState(int vKey); + + [DllImport("user32.dll")] + public static extern int ToUnicodeEx(uint vk, uint scan, byte[] state, + [Out] byte[] buf, int bufLenChars, uint flags, IntPtr layout); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetSystemTimes(out long idle, out long kernel, out long user); + + [StructLayout(LayoutKind.Sequential)] + public struct MEMORYSTATUSEX + { + public uint dwLength, dwMemoryLoad; + public ulong ullTotalPhys, ullAvailPhys, ullTotalPageFile, ullAvailPageFile, + ullTotalVirtual, ullAvailVirtual, ullAvailExtendedVirtual; + } + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX buf); + + public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam); + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] + public static extern int GetWindowTextLengthW(IntPtr hwnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowTextW(IntPtr hwnd, System.Text.StringBuilder buf, int max); + + [DllImport("user32.dll")] + public static extern IntPtr GetKeyboardLayout(uint threadId); + + public const uint TH32CS_SNAPPROCESS = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct PROCESSENTRY32W + { + public uint dwSize; + public uint cntUsage; + public uint th32ProcessID; + public IntPtr th32DefaultHeapID; + public uint th32ModuleID; + public uint cntThreads; + public uint th32ParentProcessID; + public int pcPriClassBase; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile; + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern bool Process32FirstW(IntPtr snap, ref PROCESSENTRY32W pe); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + public static extern bool Process32NextW(IntPtr snap, ref PROCESSENTRY32W pe); + + [DllImport("kernel32.dll")] + public static extern bool CloseHandle(IntPtr h); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + public const int SW_RESTORE = 9; + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetClassName(IntPtr hwnd, char[] buf, int max); + + public const uint GA_ROOT = 2; + + [DllImport("user32.dll")] + public static extern IntPtr WindowFromPoint(POINT p); + + [DllImport("user32.dll")] + public static extern IntPtr GetAncestor(IntPtr hwnd, uint flags); + + public const uint PW_RENDERFULLCONTENT = 2; + + [DllImport("user32.dll")] + public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, uint flags); + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hwnd, out RECT r); + + [DllImport("gdi32.dll")] + public static extern bool SetWindowOrgEx(IntPtr hdc, int x, int y, IntPtr prev); + + public static void EnableAcrylic(IntPtr hwnd, uint gradientColor) + { + var accent = new AccentPolicy { AccentState = ACCENT_ENABLE_ACRYLICBLURBEHIND, GradientColor = gradientColor }; + int size = Marshal.SizeOf(accent); + IntPtr ptr = Marshal.AllocHGlobal(size); + Marshal.StructureToPtr(accent, ptr, false); + var data = new WindowCompositionAttributeData { Attribute = WCA_ACCENT_POLICY, Data = ptr, SizeOfData = size }; + SetWindowCompositionAttribute(hwnd, ref data); + Marshal.FreeHGlobal(ptr); + } + + public static void RunMessageLoop() + { + while (GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) + { + TranslateMessage(ref msg); + DispatchMessage(ref msg); + } + } + + [DllImport("user32.dll")] + public static extern IntPtr GetClipboardOwner(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern uint PrivateExtractIcons(string szFile, int nIconIndex, int cxIcon, int cyIcon, + IntPtr[] phicon, int[] piconid, uint nIcons, uint flags); + + [DllImport("user32.dll")] + public static extern bool DestroyIcon(IntPtr hIcon); + + [StructLayout(LayoutKind.Sequential)] + public struct SYSTEM_POWER_STATUS + { + public byte ACLineStatus; + public byte BatteryFlag; + public byte BatteryLifePercent; + public byte SystemStatusFlag; + public int BatteryLifeTime; + public int BatteryFullLifeTime; + } + + [DllImport("kernel32.dll")] + public static extern bool GetSystemPowerStatus(out SYSTEM_POWER_STATUS status); + + public const int CF_HDROP = 15; + public const int DROPEFFECT_NONE = 0, DROPEFFECT_COPY = 1; + + [DllImport("ole32.dll")] public static extern int OleInitialize(IntPtr pvReserved); + [DllImport("ole32.dll")] public static extern void OleUninitialize(); + [DllImport("ole32.dll")] public static extern int RegisterDragDrop(IntPtr hwnd, IDropTarget target); + [DllImport("ole32.dll")] public static extern int RevokeDragDrop(IntPtr hwnd); + [DllImport("ole32.dll")] public static extern void ReleaseStgMedium(ref System.Runtime.InteropServices.ComTypes.STGMEDIUM medium); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + public static extern uint DragQueryFile(IntPtr hDrop, uint iFile, System.Text.StringBuilder? file, uint cch); + + [StructLayout(LayoutKind.Sequential)] + public struct POINTL { public int x, y; } + + [ComImport, Guid("00000122-0000-0000-C000-000000000046"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IDropTarget + { + [PreserveSig] int DragEnter([MarshalAs(UnmanagedType.Interface)] System.Runtime.InteropServices.ComTypes.IDataObject pDataObj, + int grfKeyState, POINTL pt, ref int pdwEffect); + [PreserveSig] int DragOver(int grfKeyState, POINTL pt, ref int pdwEffect); + [PreserveSig] int DragLeave(); + [PreserveSig] int Drop([MarshalAs(UnmanagedType.Interface)] System.Runtime.InteropServices.ComTypes.IDataObject pDataObj, + int grfKeyState, POINTL pt, ref int pdwEffect); + } + + public const int DROPEFFECT_MOVE = 2; + + [DllImport("ole32.dll")] + public static extern int DoDragDrop(System.Runtime.InteropServices.ComTypes.IDataObject dataObject, + IDropSource dropSource, int allowedEffects, out int finalEffect); + + [ComImport, Guid("00000121-0000-0000-C000-000000000046"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IDropSource + { + [PreserveSig] int QueryContinueDrag(int fEscapePressed, int grfKeyState); + [PreserveSig] int GiveFeedback(int dwEffect); + } + + [DllImport("shell32.dll")] + public static extern int SHDoDragDrop(IntPtr hwnd, + System.Runtime.InteropServices.ComTypes.IDataObject data, IDropSource dropSource, int allowedEffects, out int effect); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + public static extern int SHParseDisplayName(string name, IntPtr bindCtx, out IntPtr pidl, uint sfgaoIn, out uint sfgaoOut); + + [DllImport("shell32.dll")] + public static extern int SHCreateShellItemArrayFromIDLists(uint cidl, IntPtr[] rgpidl, out IShellItemArray items); + + [DllImport("shell32.dll")] + public static extern void ILFree(IntPtr pidl); + + [ComImport, Guid("b63ea76d-1f85-456f-a19c-48159efa858b"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IShellItemArray + { + + [PreserveSig] int BindToHandler(IntPtr pbc, [MarshalAs(UnmanagedType.LPStruct)] Guid bhid, + [MarshalAs(UnmanagedType.LPStruct)] Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv); + } +} diff --git a/src/Halo.App/Notifications/BtBattery.cs b/src/Halo.App/Notifications/BtBattery.cs new file mode 100644 index 0000000..a7c1765 --- /dev/null +++ b/src/Halo.App/Notifications/BtBattery.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using Windows.Devices.Bluetooth; +using Windows.Devices.Enumeration; +using Windows.Devices.Enumeration.Pnp; + +namespace Halo.Notifications; + +internal sealed class BtBattery +{ + private const string BatteryKey = "{104EA319-6EE2-4701-BD47-8DDBF425BBE5} 2"; + private const string NameKey = "System.ItemNameDisplay"; + + private readonly Action _onConnect; + private DeviceWatcher? _watcher; + private volatile bool _live; + private System.Threading.Timer? _trigger; + + private static readonly string TriggerPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "bt-test.txt"); + + private static readonly string DebugPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "bt-debug.txt"); + private static void Log(string m) { try { System.IO.File.AppendAllText(DebugPath, $"{DateTime.Now:HH:mm:ss} {m}\r\n"); } catch { } } + + public BtBattery(Action onConnect) + { + _onConnect = onConnect; + try + { + string sel = BluetoothDevice.GetDeviceSelectorFromConnectionStatus(BluetoothConnectionStatus.Connected); + _watcher = DeviceInformation.CreateWatcher(sel, new[] { NameKey }, DeviceInformationKind.AssociationEndpoint); + _watcher.Added += OnAdded; + _watcher.Removed += (_, u) => Log($"removed (disconnected): {u.Id}"); + _watcher.Updated += (_, u) => Log($"updated: {u.Id}"); + _watcher.EnumerationCompleted += (_, __) => { _live = true; Log("enumeration complete — live"); }; + _watcher.Start(); + Log("watcher started"); + } + catch (Exception ex) { Log("start failed: " + ex.Message); } + + _trigger = new System.Threading.Timer(_ => PollTrigger(), null, 1000, 1000); + } + + private void PollTrigger() + { + try + { + if (!System.IO.File.Exists(TriggerPath)) return; + var line = System.IO.File.ReadAllText(TriggerPath).Trim(); + System.IO.File.Delete(TriggerPath); + var parts = line.Split('|'); + string name = parts[0].Trim(); + int pct = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var p) ? p : 80; + if (name.Length > 0) { Log($"trigger: {name} {pct}%"); _onConnect(name, pct); } + } + catch { } + } + + private async void OnAdded(DeviceWatcher sender, DeviceInformation info) + { + try + { + if (!_live) { Log($"seed (already connected): {info.Name}"); return; } + string name = info.Name?.Length > 0 ? info.Name : "Bluetooth device"; + Log($"connected: {name}"); + int pct = await Battery(name); + if (pct < 0) { await Task.Delay(2500); pct = await Battery(name); } + Log($"banner: {name} pct={pct}"); + if (pct >= 0) _onConnect(name, pct); + } + catch (Exception ex) { Log("added failed: " + ex.Message); } + } + + private static async Task Battery(string name) + { + try + { + var objs = await PnpObject.FindAllAsync(PnpObjectType.Device, new[] { NameKey, BatteryKey }); + int best = -1; + foreach (var o in objs) + { + if (!o.Properties.TryGetValue(BatteryKey, out var bv) || bv == null) continue; + int pct = bv switch { byte b => b, int i => i, sbyte sb => sb, _ => -1 }; + if (pct < 0 || pct > 100) continue; + if (!o.Properties.TryGetValue(NameKey, out var nv) || nv is not string s) continue; + if (string.Equals(s, name, StringComparison.OrdinalIgnoreCase)) return pct; + if (best < 0 && (name.Contains(s, StringComparison.OrdinalIgnoreCase) + || s.Contains(name, StringComparison.OrdinalIgnoreCase))) best = pct; + } + return best; + } + catch { return -1; } + } +} diff --git a/src/Halo.App/Notifications/DndGate.cs b/src/Halo.App/Notifications/DndGate.cs new file mode 100644 index 0000000..4d800bd --- /dev/null +++ b/src/Halo.App/Notifications/DndGate.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Microsoft.Win32; + +namespace Halo.Notifications; + +internal static class BannerGate +{ + private const string SettingsPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings"; + private static readonly object _lock = new(); + private static readonly Dictionary _orig = new(StringComparer.OrdinalIgnoreCase); + + private static readonly string HaloDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo"); + private static readonly string StatePath = Path.Combine(HaloDir, "banner-orig.tsv"); + private static readonly string DebugPath = Path.Combine(HaloDir, "notif-debug.txt"); + private static void Log(string m) { try { File.AppendAllText(DebugPath, $"{DateTime.Now:HH:mm:ss} [banner] {m}\r\n"); } catch { } } + + private static Timer? _applyTimer; + private static long _lastRestart = -60_000; + private static long _lastToast = -QuietGapMs; + private static bool _applyPending; + private static long _applySince; + + private const int QuietGapMs = 12_000; + private const int CooldownMs = 60_000; + private const int MaxDeferMs = 30_000; + + internal static int ApplyDelayMs(long now, long lastRestart, long lastToast, + int quietGap = QuietGapMs, int cooldown = CooldownMs, + long pendingSince = 0, int maxDefer = MaxDeferMs) + { + long quiet = pendingSince > 0 && now - pendingSince >= maxDefer ? 0 : quietGap - (now - lastToast); + return (int)Math.Max(quiet, Math.Max(cooldown - (now - lastRestart), 0)); + } + + public static void Enable() + { + Log("enable (per-app banner suppression)"); + LoadState(); + lock (_lock) + { + + foreach (var aumid in new List(_orig.Keys)) + if (aumid != GlobalKey) WriteZero(aumid); + SilenceGlobalSound(); + } + SeedKnownApps(); + + ScheduleApply(); + } + + private static bool SeedKnownApps() + { + bool changed = false; + int seeded = 0; + try + { + using var root = Registry.CurrentUser.OpenSubKey(SettingsPath); + if (root == null) return false; + foreach (var aumid in Walk(root, "", 0)) + { + lock (_lock) + { + if (_orig.ContainsKey(aumid)) continue; + try { using var k = root.OpenSubKey(aumid); _orig[aumid] = k?.GetValue("ShowBanner") as int?; } + catch { _orig[aumid] = null; } + AppendState(aumid, _orig[aumid]); + if (WriteZero(aumid)) { changed = true; seeded++; } + } + } + } + catch (Exception ex) { Log("seed failed: " + ex.Message); } + if (seeded > 0) Log($"seeded {seeded} already-known app(s) from the registry"); + return changed; + } + + private static IEnumerable Walk(RegistryKey root, string prefix, int depth) + { + if (depth > 4) yield break; + string[] names; + try { using var k = prefix.Length == 0 ? null : root.OpenSubKey(prefix); names = (k ?? root).GetSubKeyNames(); } + catch { yield break; } + foreach (var name in names) + { + if (string.IsNullOrEmpty(name)) continue; + string full = prefix.Length == 0 ? name : prefix + "\\" + name; + yield return full; + foreach (var child in Walk(root, full, depth + 1)) yield return child; + } + } + + public static void SuppressApp(string aumid) + { + if (string.IsNullOrEmpty(aumid)) return; + bool changed; + lock (_lock) + { + + _lastToast = Environment.TickCount64; + if (!_orig.ContainsKey(aumid)) + { + try { using var k = Registry.CurrentUser.OpenSubKey(SettingsPath + "\\" + aumid); _orig[aumid] = k?.GetValue("ShowBanner") as int?; } + catch { _orig[aumid] = null; } + AppendState(aumid, _orig[aumid]); + } + changed = WriteZero(aumid); + } + if (changed) ScheduleApply(); else Defer(); + } + + private static void Defer() + { + lock (_lock) + if (_applyPending) + _applyTimer?.Change(ApplyDelayMs(Environment.TickCount64, _lastRestart, _lastToast, + pendingSince: _applySince), + Timeout.Infinite); + } + + private static readonly string[] SilenceKeys = { "ShowBanner", "Sound", "AllowUrgentNotifications" }; + private static bool WriteZero(string aumid) + { + try + { + using var k = Registry.CurrentUser.CreateSubKey(SettingsPath + "\\" + aumid, writable: true); + if (k == null) return false; + bool changed = false; + foreach (var name in SilenceKeys) + if ((k.GetValue(name) as int?) != 0) { k.SetValue(name, 0, RegistryValueKind.DWord); changed = true; } + if (changed) Log($"silenced (banner+sound+urgent) → {aumid}"); + return changed; + } + catch (Exception ex) { Log($"suppress {aumid} failed: {ex.Message}"); return false; } + } + + private const string GlobalSoundValue = "NOC_GLOBAL_SETTING_ALLOW_NOTIFICATION_SOUND"; + private const string GlobalKey = "global"; + + private static bool SilenceGlobalSound() + { + try + { + using var k = Registry.CurrentUser.CreateSubKey(SettingsPath, writable: true); + if (k == null) return false; + var now = k.GetValue(GlobalSoundValue) as int?; + if (now == 0) return false; + if (!_orig.ContainsKey(GlobalKey)) + { + _orig[GlobalKey] = now; + AppendState(GlobalKey, now); + } + k.SetValue(GlobalSoundValue, 0, RegistryValueKind.DWord); + Log($"silenced global notification sound (was {now?.ToString() ?? "unset"})"); + return true; + } + catch (Exception ex) { Log("global sound off failed: " + ex.Message); return false; } + } + + private static void RestoreGlobalSound() + { + if (!_orig.TryGetValue(GlobalKey, out var prior)) return; + try + { + using var k = Registry.CurrentUser.OpenSubKey(SettingsPath, writable: true); + if (k == null) return; + if (prior is int p) k.SetValue(GlobalSoundValue, p, RegistryValueKind.DWord); + else k.DeleteValue(GlobalSoundValue, throwOnMissingValue: false); + Log("restored global notification sound"); + } + catch { } + } + + private static void ScheduleApply() + { + lock (_lock) + { + _applyTimer ??= new Timer(_ => DoApply(), null, Timeout.Infinite, Timeout.Infinite); + if (!_applyPending) _applySince = Environment.TickCount64; + _applyPending = true; + _applyTimer.Change(ApplyDelayMs(Environment.TickCount64, _lastRestart, _lastToast, + pendingSince: _applySince), + Timeout.Infinite); + } + } + + private static void DoApply() + { + lock (_lock) + { + + int wait = ApplyDelayMs(Environment.TickCount64, _lastRestart, _lastToast, + pendingSince: _applySince); + if (wait > 0) { _applyTimer?.Change(wait, Timeout.Infinite); return; } + _lastRestart = Environment.TickCount64; + _applyPending = false; + _applySince = 0; + } + Log("applying → WpnUserService restart (listener self-heals)"); + RestartService(); + } + + private static void RestartService() + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell", + Arguments = "-NoProfile -WindowStyle Hidden -Command \"Restart-Service -Name 'WpnUserService_*' -Force\"", + UseShellExecute = false, + CreateNoWindow = true, + }); + } + catch (Exception ex) { Log("restart failed: " + ex.Message); } + } + + public static void Restore() + { + lock (_lock) + { + RestoreGlobalSound(); + foreach (var (aumid, prior) in _orig) + { + if (aumid == GlobalKey) continue; + try + { + using var k = Registry.CurrentUser.OpenSubKey(SettingsPath + "\\" + aumid, writable: true); + if (k == null) continue; + if (prior is int p) k.SetValue("ShowBanner", p, RegistryValueKind.DWord); + else k.DeleteValue("ShowBanner", throwOnMissingValue: false); + + k.DeleteValue("Sound", throwOnMissingValue: false); + k.DeleteValue("AllowUrgentNotifications", throwOnMissingValue: false); + } + catch { } + } + Log("restored native banners"); + } + } + + public static void Uninstall() + { + LoadState(); + Restore(); + RestartService(); + try { File.Delete(StatePath); } catch { } + Log("uninstall: restored + cleared state"); + } + + private static void LoadState() + { + try + { + if (!File.Exists(StatePath)) return; + foreach (var line in File.ReadAllLines(StatePath)) + { + int tab = line.IndexOf('\t'); + if (tab <= 0) continue; + _orig[line.Substring(0, tab)] = int.TryParse(line.Substring(tab + 1), out var n) ? n : (int?)null; + } + Log($"loaded {_orig.Count} learned app(s)"); + } + catch (Exception ex) { Log("load state failed: " + ex.Message); } + } + + private static void AppendState(string aumid, int? orig) + { + try { Directory.CreateDirectory(HaloDir); File.AppendAllText(StatePath, $"{aumid}\t{orig?.ToString() ?? ""}\r\n"); } + catch { } + } +} diff --git a/src/Halo.App/Notifications/NotifSource.cs b/src/Halo.App/Notifications/NotifSource.cs new file mode 100644 index 0000000..560e205 --- /dev/null +++ b/src/Halo.App/Notifications/NotifSource.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Windows.UI.Notifications; +using Windows.UI.Notifications.Management; + +namespace Halo.Notifications; + +internal sealed class NotifItem +{ + + public const string ScreenshotApp = "Screenshot"; + public const string ClipboardApp = "Clipboard"; + public const string ScreenshotTitle = "Screenshot captured"; + public const string ImageCopiedTitle = "Image copied"; + + public uint Id; + public DateTime Time = DateTime.Now; + public string App = ""; + public string Title = ""; + public string Body = ""; + public string Aumid = ""; + public System.Drawing.Bitmap? Icon; + public System.Drawing.Bitmap? Preview; + public string LaunchPath = ""; + public string Kind = ""; + public double Duration = 6; + public Action? OnActivate; + public string Code = ""; + public bool Copied; + public int Stacked; + + public void Activate() + { + + if (OnActivate != null) { try { OnActivate(); } catch { } return; } + + if (LaunchPath.Length > 0) + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = LaunchPath, UseShellExecute = true }); return; } + catch { } + } + + var (launch, actType) = WpnDb.LaunchFor(Id); + + if (launch.Length > 0 && (actType.Equals("protocol", StringComparison.OrdinalIgnoreCase) || LooksLikeUri(launch))) + { + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = launch, UseShellExecute = true }); return; } + catch { } + } + if (Aumid.Length == 0) return; + try { ((IApplicationActivationManager)new ApplicationActivationManager()).ActivateApplication(Aumid, launch, 0, out _); return; } + catch { } + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { FileName = "explorer.exe", Arguments = "shell:AppsFolder\\" + Aumid, UseShellExecute = true }); + } + catch { } + } + + private static bool LooksLikeUri(string s) + { + int c = s.IndexOf(':'); + if (c <= 0 || s.IndexOf('|') >= 0 || !char.IsLetter(s[0])) return false; + for (int i = 1; i < c; i++) + { + char ch = s[i]; + if (!char.IsLetterOrDigit(ch) && ch != '+' && ch != '-' && ch != '.') return false; + } + return true; + } +} + +[System.Runtime.InteropServices.ComImport, + System.Runtime.InteropServices.Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] +internal class ApplicationActivationManager { } + +[System.Runtime.InteropServices.ComImport, + System.Runtime.InteropServices.Guid("2e941141-7f97-4756-ba1d-9decde894a3d"), + System.Runtime.InteropServices.InterfaceType(System.Runtime.InteropServices.ComInterfaceType.InterfaceIsIUnknown)] +internal interface IApplicationActivationManager +{ + void ActivateApplication( + [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string appUserModelId, + [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string arguments, + uint options, out uint processId); +} + +internal sealed class NotifSource +{ + private readonly object _lock = new(); + private readonly Queue _pending = new(); + private UserNotificationListener? _listener; + private System.Threading.Timer? _poll; + private uint _seenMaxId; + private bool _baselined; + private readonly long _startTick = Environment.TickCount64; + private long _lastReheal; + private int _version; + + public NotifSource() => _ = InitAsync(); + + public int Version { get { lock (_lock) { return _version; } } } + + private bool Stack(NotifItem item) + { + foreach (var queued in _pending) + { + if (!string.Equals(queued.Aumid, item.Aumid, StringComparison.OrdinalIgnoreCase)) continue; + if (!string.Equals(queued.Title, item.Title, StringComparison.Ordinal)) continue; + queued.Stacked++; + queued.Body = item.Body; + queued.Time = item.Time; + + queued.Duration = Math.Min(12, queued.Duration + 1.5); + return true; + } + return false; + } + + public NotifItem? Dequeue() + { + lock (_lock) { return _pending.Count > 0 ? _pending.Dequeue() : null; } + } + + public bool HasPending { get { lock (_lock) { return _pending.Count > 0; } } } + + public void EnqueueLocal(NotifItem item) + { + lock (_lock) { _pending.Enqueue(item); _version++; } + } + + public void DropPending(string kind) + { + lock (_lock) + { + if (_pending.Count == 0) return; + var keep = new Queue(); + foreach (var it in _pending) if (it.Kind != kind) keep.Enqueue(it); + _pending.Clear(); + foreach (var it in keep) _pending.Enqueue(it); + } + } + + private static readonly string DebugPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "notif-debug.txt"); + + private static readonly string SeenPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "notif-seen.txt"); + + private void LoadSeen() + { + try { if (uint.TryParse((System.IO.File.ReadAllText(SeenPath) ?? "").Trim(), out var v) && v > 0) { _seenMaxId = v; _baselined = true; } } + catch { } + } + + private static void SaveSeen(uint v) { try { System.IO.File.WriteAllText(SeenPath, v.ToString()); } catch { } } + + private static void Log(string msg) + { + try + { + System.IO.File.AppendAllText(DebugPath, $"{DateTime.Now:HH:mm:ss} {msg}\r\n"); + } + catch { } + } + + private async Task InitAsync() + { + try + { + _listener = UserNotificationListener.Current; + var access = await _listener.RequestAccessAsync(); + Log($"access = {access}"); + if (access != UserNotificationListenerAccessStatus.Allowed) return; + LoadSeen(); + + await Refresh(); + + try { _listener.NotificationChanged += (s, e) => { _ = Refresh(); }; } + catch (Exception ex) { Log("event hook failed: " + ex.Message); } + + _poll = new System.Threading.Timer(_ => { _ = Refresh(); }, null, 250, 250); + } + catch (Exception ex) { Log("init failed: " + ex); } + } + + private async Task Refresh() + { + if (_listener == null) return; + try + { + var notes = await _listener.GetNotificationsAsync(NotificationKinds.Toast); + lock (_lock) + { + uint maxId = _seenMaxId; + foreach (var n in notes) if (n.Id > maxId) maxId = n.Id; + + if (!_baselined) + { + if (notes.Count == 0 && Environment.TickCount64 - _startTick < 3000) return; + _seenMaxId = maxId; + _baselined = true; + SaveSeen(maxId); + Log($"baseline maxId = {maxId}"); + return; + } + + foreach (var n in notes) + { + if (n.Id <= _seenMaxId) continue; + var item = Build(n); + if (item != null) + { + BannerGate.SuppressApp(item.Aumid); + if (Stack(item)) continue; + _pending.Enqueue(item); + + try { _listener.RemoveNotification(n.Id); } catch { } + } + } + if (maxId > _seenMaxId) { Log($"new toasts up to id {maxId}, queued {_pending.Count}"); _seenMaxId = maxId; SaveSeen(maxId); } + if (_pending.Count > 0) _version++; + } + } + catch (Exception ex) { Log("refresh failed: " + ex.Message); TryReheal(); } + } + + private void TryReheal() + { + if (Environment.TickCount64 - _lastReheal < 5_000) return; + _lastReheal = Environment.TickCount64; + _ = Reheal(); + } + + private async Task Reheal() + { + try + { + var l = UserNotificationListener.Current; + if (await l.RequestAccessAsync() != UserNotificationListenerAccessStatus.Allowed) return; + _listener = l; + Log("listener re-acquired after service restart"); + } + catch (Exception ex) { Log("reheal failed: " + ex.Message); } + } + + private static NotifItem? Build(UserNotification n) + { + string app = "", title = "", body = "", aumid = ""; + try { app = n.AppInfo?.DisplayInfo?.DisplayName ?? ""; } + catch (Exception ex) { Log("appinfo failed: " + ex.Message); } + try { aumid = n.AppInfo?.AppUserModelId ?? ""; } catch { } + try + { + var bind = n.Notification?.Visual?.GetBinding(KnownNotificationBindings.ToastGeneric); + if (bind != null) + { + var texts = bind.GetTextElements(); + if (texts.Count > 0) title = texts[0].Text ?? ""; + for (int i = 1; i < texts.Count; i++) + body += (body.Length > 0 ? " " : "") + texts[i].Text; + } + } + catch (Exception ex) { Log("texts failed: " + ex.Message); } + Log($"toast {n.Id}: aumid='{aumid}' app='{app}' t={title.Length} b={body.Length}"); + if (title.Length == 0 && body.Length == 0) + { + + if (app.Length == 0) return null; + title = app; + } + + System.Drawing.Bitmap? icon = ShellIcon.ForAumid(aumid); + icon ??= Halo.Widgets.AppIcon.ForAumid(aumid); + if (icon == null) { try { icon = Logo(n); } catch { } } + icon ??= ShellIcon.ForAppName(app); + return new NotifItem { Id = n.Id, App = app, Title = title, Body = body, Aumid = aumid, Icon = icon, Code = DetectCode(title, body) }; + } + + private static readonly System.Text.RegularExpressions.Regex CodeRx = + new(@"(? _ok = new(StringComparer.OrdinalIgnoreCase); + private static readonly System.Collections.Generic.Dictionary _missed = new(StringComparer.OrdinalIgnoreCase); + + public static Bitmap? ForAumid(string aumid) + { + if (string.IsNullOrEmpty(aumid)) return null; + lock (_ok) + { + if (_ok.TryGetValue(aumid, out var c)) return c; + if (_missed.TryGetValue(aumid, out var t) && Environment.TickCount64 - t < 5000) return null; + } + var bmp = ExtractFrom("shell:AppsFolder\\" + aumid); + lock (_ok) + { + if (bmp != null) { _ok[aumid] = bmp; _missed.Remove(aumid); } + else _missed[aumid] = Environment.TickCount64; + } + return bmp; + } + + public static Bitmap? ForAppName(string? appName) + { + if (string.IsNullOrWhiteSpace(appName)) return null; + string key = "\0name:" + appName; + lock (_ok) + { + if (_ok.TryGetValue(key, out var c)) return c; + if (_missed.TryGetValue(key, out var t) && Environment.TickCount64 - t < 30000) return null; + } + var lnk = StartMenuLnk(appName); + var bmp = lnk == null ? null : ExtractFrom(lnk); + lock (_ok) + { + if (bmp != null) { _ok[key] = bmp; _missed.Remove(key); } + else _missed[key] = Environment.TickCount64; + } + return bmp; + } + + public static Bitmap? ForPath(string? path) + { + if (string.IsNullOrEmpty(path)) return null; + string key = "\0path:" + path; + lock (_ok) + { + if (_ok.TryGetValue(key, out var c)) return c; + if (_missed.TryGetValue(key, out var t) && Environment.TickCount64 - t < 5000) return null; + } + var bmp = ExtractFrom(path); + lock (_ok) + { + if (bmp != null) { _ok[key] = bmp; _missed.Remove(key); } + else _missed[key] = Environment.TickCount64; + } + return bmp; + } + + private static (string name, string path)[] _lnks = Array.Empty<(string, string)>(); + private static long _lnksAt; + + private static string? StartMenuLnk(string appName) + { + string tok = FirstWord(appName); + if (tok.Length < 3) return null; + if (_lnks.Length == 0 || Environment.TickCount64 - _lnksAt > 60000) + { + var list = new System.Collections.Generic.List<(string, string)>(); + foreach (var root in new[] { + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu) }) + { + try + { + var dir = System.IO.Path.Combine(root, "Programs"); + if (System.IO.Directory.Exists(dir)) + foreach (var f in System.IO.Directory.EnumerateFiles(dir, "*.lnk", System.IO.SearchOption.AllDirectories)) + list.Add((System.IO.Path.GetFileNameWithoutExtension(f), f)); + } + catch { } + } + _lnks = list.ToArray(); _lnksAt = Environment.TickCount64; + } + string? best = null; + foreach (var (name, path) in _lnks) + { + if (name.Equals(tok, StringComparison.OrdinalIgnoreCase)) return path; + if (best == null && name.StartsWith(tok, StringComparison.OrdinalIgnoreCase)) best = path; + } + return best; + } + + private static string FirstWord(string s) + { + int i = 0; while (i < s.Length && !char.IsLetterOrDigit(s[i])) i++; + int j = i; while (j < s.Length && char.IsLetterOrDigit(s[j])) j++; + return s.Substring(i, j - i); + } + + private static Bitmap? ExtractFrom(string parsingName) + { + IntPtr hbmp = IntPtr.Zero; + try + { + var iid = typeof(IShellItemImageFactory).GUID; + if (SHCreateItemFromParsingName(parsingName, IntPtr.Zero, ref iid, out var f) != 0 || f == null) + return null; + + f.GetImage(new SIZE { cx = 256, cy = 256 }, SIIGBF_ICONONLY | SIIGBF_BIGGERSIZEOK, out hbmp); + Marshal.ReleaseComObject(f); + if (hbmp == IntPtr.Zero) return null; + + var ds = new DIBSECTION(); + if (GetObject(hbmp, Marshal.SizeOf(), ref ds) < Marshal.SizeOf() || ds.dsBm.bmBits == IntPtr.Zero) + { using var fb = Image.FromHbitmap(hbmp); return new Bitmap(fb); } + var bm = ds.dsBm; + using var src = new Bitmap(bm.bmWidth, bm.bmHeight, bm.bmWidthBytes, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb, bm.bmBits); + var copy = new Bitmap(src); + + if (ds.dsBmih.biHeight > 0) copy.RotateFlip(RotateFlipType.RotateNoneFlipY); + return Trim(copy); + } + catch { return null; } + finally { if (hbmp != IntPtr.Zero) DeleteObject(hbmp); } + } + + private static Bitmap? Trim(Bitmap b) + { + int minX = b.Width, minY = b.Height, maxX = -1, maxY = -1; + for (int y = 0; y < b.Height; y++) + for (int x = 0; x < b.Width; x++) + if (b.GetPixel(x, y).A > 90) + { + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + + if (maxX < minX) { b.Dispose(); return null; } + int w = maxX - minX + 1, h = maxY - minY + 1; + if (w >= b.Width - 1 && h >= b.Height - 1) return b; + var crop = new Bitmap(w, h, System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(crop)) + g.DrawImage(b, new Rectangle(0, 0, w, h), new Rectangle(minX, minY, w, h), GraphicsUnit.Pixel); + b.Dispose(); + return crop; + } + + private const int SIIGBF_BIGGERSIZEOK = 0x1; + private const int SIIGBF_ICONONLY = 0x4; + + [StructLayout(LayoutKind.Sequential)] + private struct SIZE { public int cx, cy; } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAP + { + public int bmType, bmWidth, bmHeight, bmWidthBytes; + public ushort bmPlanes, bmBitsPixel; + public IntPtr bmBits; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BITMAPINFOHEADER + { + public uint biSize; + public int biWidth, biHeight; + public ushort biPlanes, biBitCount; + public uint biCompression, biSizeImage; + public int biXPelsPerMeter, biYPelsPerMeter; + public uint biClrUsed, biClrImportant; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DIBSECTION + { + public BITMAP dsBm; + public BITMAPINFOHEADER dsBmih; + public uint dsBitfield0, dsBitfield1, dsBitfield2; + public IntPtr dshSection; + public uint dsOffset; + } + + [DllImport("gdi32.dll")] + private static extern int GetObject(IntPtr h, int c, ref DIBSECTION pv); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHCreateItemFromParsingName( + string path, IntPtr pbc, ref Guid riid, out IShellItemImageFactory ppv); + + [DllImport("gdi32.dll")] + private static extern bool DeleteObject(IntPtr hObject); + + [ComImport, Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItemImageFactory + { + void GetImage(SIZE size, int flags, out IntPtr phbm); + } +} diff --git a/src/Halo.App/Notifications/WpnDb.cs b/src/Halo.App/Notifications/WpnDb.cs new file mode 100644 index 0000000..4b9bf3e --- /dev/null +++ b/src/Halo.App/Notifications/WpnDb.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Xml.Linq; + +namespace Halo.Notifications; + +internal static class WpnDb +{ + private static readonly string DbPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "Windows", "Notifications", "wpndatabase.db"); + + public static (string launch, string activationType) LaunchFor(uint id) + { + string tmp = Path.Combine(Path.GetTempPath(), "halo-wpn-" + Guid.NewGuid().ToString("N") + ".db"); + try + { + using (var s = new FileStream(DbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var d = new FileStream(tmp, FileMode.Create, FileAccess.Write)) + s.CopyTo(d); + + var xml = QueryPayload(tmp, id); + if (string.IsNullOrEmpty(xml)) return ("", ""); + var root = XDocument.Parse(xml).Root; + if (root is null || root.Name.LocalName != "toast") return ("", ""); + return ((string?)root.Attribute("launch") ?? "", + (string?)root.Attribute("activationType") ?? ""); + } + catch { return ("", ""); } + finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } } + } + + private static string QueryPayload(string path, uint id) + { + if (sqlite3_open_v2(U8(path), out var db, SQLITE_OPEN_READONLY, IntPtr.Zero) != 0) return ""; + try + { + if (sqlite3_prepare_v2(db, U8("SELECT Payload FROM Notification WHERE Id=" + id), -1, out var st, IntPtr.Zero) != 0) + return ""; + try + { + if (sqlite3_step(st) != SQLITE_ROW) return ""; + var p = sqlite3_column_blob(st, 0); + int n = sqlite3_column_bytes(st, 0); + if (p == IntPtr.Zero || n <= 0) return ""; + var b = new byte[n]; + Marshal.Copy(p, b, 0, n); + return Encoding.UTF8.GetString(b); + } + finally { sqlite3_finalize(st); } + } + finally { sqlite3_close(db); } + } + + private static byte[] U8(string s) => Encoding.UTF8.GetBytes(s + "\0"); + + private const int SQLITE_OPEN_READONLY = 1, SQLITE_ROW = 100; + private const string Dll = "winsqlite3.dll"; + [DllImport(Dll)] private static extern int sqlite3_open_v2(byte[] f, out IntPtr db, int flags, IntPtr vfs); + [DllImport(Dll)] private static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int n, out IntPtr stmt, IntPtr tail); + [DllImport(Dll)] private static extern int sqlite3_step(IntPtr stmt); + [DllImport(Dll)] private static extern IntPtr sqlite3_column_blob(IntPtr stmt, int c); + [DllImport(Dll)] private static extern int sqlite3_column_bytes(IntPtr stmt, int c); + [DllImport(Dll)] private static extern int sqlite3_finalize(IntPtr stmt); + [DllImport(Dll)] private static extern int sqlite3_close(IntPtr db); +} diff --git a/src/Halo.App/Program.cs b/src/Halo.App/Program.cs new file mode 100644 index 0000000..13fd1b1 --- /dev/null +++ b/src/Halo.App/Program.cs @@ -0,0 +1,1267 @@ +using System; +using System.Linq; +using Halo.Interop; +using Halo.Shell; +using Halo.Widgets; + +namespace Halo; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + + if (args.Length >= 1 && args[0] == "--restore-notifications") { Halo.Notifications.BannerGate.Uninstall(); return; } + + if (args.Length >= 2 && args[0] == "--render-widget") + { + RenderWidget(args[1], args.Length > 2 ? args[2] : "media", + args.Length > 3 && int.TryParse(args[3], out int sc) ? sc : 1, args); + return; + } + + if (args.Length >= 2 && args[0] == "--render-pill") { RenderPill(args[1]); return; } + + if (args.Length >= 1 && args[0] == "--probe-almanac") + { + Console.WriteLine($"zone {TimeZoneInfo.Local.Id}"); + Console.WriteLine($"place {Almanac.Place ?? "(none - offset-only zone)"}"); + Almanac.Poke(); + for (int i = 0; i < 60 && Almanac.Latest is null; i++) System.Threading.Thread.Sleep(500); + Console.WriteLine($"weather {(Almanac.Latest is { } wx ? $"{wx.TempC}C code {wx.Code} = {Almanac.Sky(wx.Code)}" : "(no reading)")}"); + Console.WriteLine($"country {Almanac.PlaceCountry ?? "(not geocoded)"} metric {Almanac.Metric} calendar {Almanac.Calendar}"); + Console.WriteLine($"source {(Almanac.FromDevice ? "windows location" : "time zone")}"); + Console.WriteLine($"label {Almanac.Label}"); + Console.WriteLine($"title {Almanac.Headline(DateTime.Now)}"); + Console.WriteLine($"body {Almanac.Detail(DateTime.Now)}"); + return; + } + + if (args.Length >= 2 && args[0] == "--render-pin") { RenderPin(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-notif") { RenderNotif(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-badges") { RenderBadges(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-ask") { RenderAsk(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-greeting") { RenderGreeting(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-local") { RenderLocal(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-copy") { RenderCopy(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--render-glyphs") { RenderGlyphs(args[1]); return; } + + if (args.Length >= 3 && args[0] == "--probe-console") + { + int.TryParse(args[1], out int cpid); + int cbelow = args.Length > 3 && int.TryParse(args[3], out var cb) ? cb : 0; + System.IO.File.WriteAllText(args[2], Halo.Interop.ConsoleRead.Describe(cpid, 16, cbelow)); + return; + } + + if (args.Length >= 4 && args[0] == "--probe-type") + { + int.TryParse(args[1], out int tpid); + + bool sent = args[2] switch + { + "enter" => Halo.Interop.ConsoleRead.Press(tpid, Halo.Interop.ConsoleRead.VkEnter), + "tab" => Halo.Interop.ConsoleRead.Press(tpid, Halo.Interop.ConsoleRead.VkTab), + var s when s.StartsWith("down:") && int.TryParse(s[5..], out var n) + => Halo.Interop.ConsoleRead.Press(tpid, Halo.Interop.ConsoleRead.VkDown, n), + var s when s.StartsWith("up:") && int.TryParse(s[3..], out var n) + => Halo.Interop.ConsoleRead.Press(tpid, Halo.Interop.ConsoleRead.VkUp, n), + _ => Halo.Interop.ConsoleRead.Type(tpid, args[2]), + }; + System.Threading.Thread.Sleep(400); + System.IO.File.WriteAllText(args[3], $"sent={sent}\n" + Halo.Interop.ConsoleRead.Dump(tpid, 10)); + return; + } + if (args.Length >= 2 && args[0] == "--render-fluent") + { RenderFluent(args[1], args.Length > 2 ? args[2] : "E700", args.Length > 3 ? args[3] : "256"); return; } + + if (args.Length >= 2 && args[0] == "--render-bar") + { RenderBar(args[1], args.Length > 2 ? args[2] : null, args.Length > 3 ? args[3] : null); return; } + + if (args.Length >= 1 && args[0] == "--probe-media") { ProbeMedia(); return; } + + if (args.Length >= 2 && args[0] == "--probe-size") + { + var title = args[1]; + Console.WriteLine($"title {title}"); + Console.WriteLine($"looks like a file {Halo.Widgets.MediaFileInfo.LooksLikeFile(title)}"); + Halo.Widgets.MediaFileInfo.Size(title); + for (int i = 0; i < 40; i++) + { + System.Threading.Thread.Sleep(100); + if (Halo.Widgets.MediaFileInfo.Size(title) is { } b) + { Console.WriteLine($"size {b:N0} bytes = {Halo.Widgets.MediaFileInfo.Human(b)}"); return; } + } + Console.WriteLine("size (not found)"); + return; + } + + if (args.Length >= 2 && args[0] == "--probe-seek") { ProbeSeek(double.Parse(args[1], + System.Globalization.CultureInfo.InvariantCulture), args.Length > 2 ? int.Parse(args[2]) : 1); return; } + + if (args.Length >= 2 && args[0] == "--probe-downloads") { ProbeDownloads(args[1]); return; } + + if (args.Length >= 1 && args[0] == "--cancel-download") { CancelDownload(); return; } + + if (args.Length >= 2 && args[0] == "--probe-icon") { ProbeIcon(args[1]); return; } + + if (args.Length >= 2 && args[0] == "--probe-tree") { ProbeTree(int.Parse(args[1])); return; } + + if (args.Length >= 2 && args[0] == "--render-shape") + { + + if (args.Length >= 4) + { + var parts = args[3].Split(','); + float P(int i, float dflt) => i < parts.Length && float.TryParse(parts[i], + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : dflt; + Halo.Shell.LayeredNotch.FrostMix = P(0, Halo.Shell.LayeredNotch.FrostMix); + Halo.Shell.LayeredNotch.Sheen = P(1, Halo.Shell.LayeredNotch.Sheen); + Halo.Shell.LayeredNotch.Grain = P(2, Halo.Shell.LayeredNotch.Grain); + Halo.Shell.LayeredNotch.RimLight = P(3, Halo.Shell.LayeredNotch.RimLight); + } + + System.Drawing.Bitmap back; + if (args.Length >= 3 && System.IO.File.Exists(args[2])) + { + using var src0 = new System.Drawing.Bitmap(args[2]); + using var fit0 = new System.Drawing.Bitmap(560, 220, System.Drawing.Imaging.PixelFormat.Format24bppRgb); + using (var bgg0 = System.Drawing.Graphics.FromImage(fit0)) + bgg0.DrawImage(src0, new System.Drawing.Rectangle(0, 0, 560, 220)); + + back = Halo.Shell.LayeredNotch.BlurPyramid(fit0); + } + else + { + back = new System.Drawing.Bitmap(560, 220, System.Drawing.Imaging.PixelFormat.Format24bppRgb); + using var bgg = System.Drawing.Graphics.FromImage(back); + bgg.Clear(System.Drawing.Color.Magenta); + } + using var _back = back; + using var shot = new System.Drawing.Bitmap(560, 220, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using (var sg = System.Drawing.Graphics.FromImage(shot)) + { + sg.Clear(System.Drawing.Color.Transparent); + Halo.Shell.LayeredNotch.ShapeInto(sg, 560, 220, 30, NotchController.TintAppExpanded, back, 1f); + } + shot.Save(args[1], System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine("wrote " + args[1]); + return; + } + + if (args.Length >= 1 && args[0] == "--probe-ip") + { + Halo.ClaudeCode.IpCountry.Poke(); + System.Threading.Thread.Sleep(5000); + Console.WriteLine($"ip={Halo.ClaudeCode.IpCountry.Ip} cc={Halo.ClaudeCode.IpCountry.Cc} " + + $"isp={Halo.ClaudeCode.IpCountry.Isp} asn={Halo.ClaudeCode.IpCountry.Asn}"); + Console.WriteLine($"apiIp={Halo.ClaudeCode.IpCountry.ApiIp} apiCc={Halo.ClaudeCode.IpCountry.ApiCc} " + + $"split={Halo.ClaudeCode.IpCountry.Split}"); + string? scored = Halo.ClaudeCode.IpCountry.Split + ? Halo.ClaudeCode.IpCountry.ApiIp : Halo.ClaudeCode.IpCountry.Ip; + Halo.ClaudeCode.IpRep.Want(scored); + System.Threading.Thread.Sleep(5000); + Console.WriteLine($"scored={scored} forIp={Halo.ClaudeCode.IpRep.ForIp} " + + $"verdict={Halo.ClaudeCode.IpRep.Verdict} abuse={Halo.ClaudeCode.IpRep.Abuse} " + + $"sev={Halo.ClaudeCode.IpRep.Sev}"); + Halo.ClaudeCode.DnsLeak.Want(scored, + Halo.ClaudeCode.IpCountry.Split ? Halo.ClaudeCode.IpCountry.ApiCc : Halo.ClaudeCode.IpCountry.Cc); + for (int i = 0; i < 40 && !Halo.ClaudeCode.DnsLeak.Done; i++) System.Threading.Thread.Sleep(500); + Console.WriteLine($"dns done={Halo.ClaudeCode.DnsLeak.Done} resolvers={Halo.ClaudeCode.DnsLeak.Resolvers} " + + $"where={Halo.ClaudeCode.DnsLeak.Where} leaking={Halo.ClaudeCode.DnsLeak.Leaking}"); + Console.WriteLine("mark=" + Halo.ClaudeCode.IpRep.Score( + Halo.ClaudeCode.IpRep.Tor, Halo.ClaudeCode.IpRep.Abuser, Halo.ClaudeCode.IpRep.Bogon, + Halo.ClaudeCode.IpRep.Vpn, Halo.ClaudeCode.IpRep.Proxy, Halo.ClaudeCode.IpRep.Datacenter, + Halo.ClaudeCode.IpRep.Abuse, Halo.ClaudeCode.IpCountry.Split, Halo.ClaudeCode.DnsLeak.Leaking)); + return; + } + + if (args.Length >= 1 && args[0] == "--probe-spectrum") + { + for (int i = 0; i < 20; i++) + { + var b = Halo.Widgets.AudioSpectrum.Bands(); + Console.WriteLine($"avail={Halo.Widgets.AudioSpectrum.Available} " + + string.Join(" ", Array.ConvertAll(b, v => v.ToString("0.00")))); + System.Threading.Thread.Sleep(300); + } + return; + } + + if (args.Length >= 1 && args[0] == "--probe-timeline") + { + + var probe = new System.Threading.Thread(() => ProbeTimeline()); + probe.SetApartmentState(System.Threading.ApartmentState.MTA); + probe.Start(); + probe.Join(); + return; + } + + if (args.Length >= 1 && args[0] == "--moods") { Moods(); return; } + + _instance = new System.Threading.Mutex(true, "Halo.Notch.SingleInstance", out bool created); + if (!created) { OpenSettingsPanel(); return; } + if (args.Contains("--settings", StringComparer.OrdinalIgnoreCase)) OpenSettingsPanel(); + + try + { + Win32.OleInitialize(IntPtr.Zero); + var notch = new LayeredNotch(); + notch.Show(); + Halo.ClaudeCode.Limits.Poke(); + Halo.ClaudeCode.NetMon.Poke(); + Halo.Codex.CodexNetMon.Poke(); + _ = new NotchController(notch); + _tray = new Halo.Shell.TrayIcon(); + Win32.RunMessageLoop(); + } + catch (Exception ex) + { + System.IO.File.WriteAllText( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "halo-crash.log"), + ex.ToString()); + throw; + } + } + + private static void Moods() + { + int keys = 0, lines = 0; + foreach (var key in Halo.Agents.Moods.Keys) + { + var set = Halo.Agents.Moods.Set(key); + keys++; lines += set.Length; + Console.WriteLine($"{key,-18} {set.Length,2} {string.Join(" · ", set)}"); + } + Console.WriteLine(); + Console.WriteLine($"{keys} keys, {lines} lines, none of them generated at runtime."); + } + + private static void ProbeTree(int pid) + { + var map = new System.Collections.Generic.Dictionary(); + var snap = Halo.Interop.Win32.CreateToolhelp32Snapshot(Halo.Interop.Win32.TH32CS_SNAPPROCESS, 0); + var pe = new Halo.Interop.Win32.PROCESSENTRY32W + { dwSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (Halo.Interop.Win32.Process32FirstW(snap, ref pe)) + do { map[(int)pe.th32ProcessID] = (int)pe.th32ParentProcessID; } + while (Halo.Interop.Win32.Process32NextW(snap, ref pe)); + Halo.Interop.Win32.CloseHandle(snap); + Console.WriteLine($"snapshot has {map.Count} processes"); + int p = pid, guard = 0; + while (p > 4 && guard++ < 20) { Console.WriteLine($" {p}"); if (!map.TryGetValue(p, out p)) break; } + } + + private static void CancelDownload() + { + Halo.Widgets.Downloads.Scan(); + if (Halo.Widgets.Downloads.Count == 0) { Console.WriteLine("nothing downloading"); return; } + string? file = Halo.Widgets.Downloads.FilePath; + Console.WriteLine($"cancelling '{Halo.Widgets.Downloads.Name}' file='{file}'"); + long before = -1; + try { if (file != null) before = new System.IO.FileInfo(file).Length; } catch { } + + Halo.Widgets.Downloads.CancelDownload(); + System.Threading.Thread.Sleep(14000); + + long after = -1; + try { if (file != null && System.IO.File.Exists(file)) after = new System.IO.FileInfo(file).Length; } catch { } + Console.WriteLine(after < 0 ? "partial is gone -> stopped" + : after == before ? $"partial held at {before:n0} -> stopped" + : $"partial grew {before:n0} -> {after:n0} -> STILL RUNNING"); + } + + private static void ProbeDownloads(string outPath) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"{DateTime.Now:HH:mm:ss} probe-downloads"); + + sb.AppendLine("\nChromiumProgress.Live():"); + var live = Halo.Widgets.ChromiumProgress.Live(); + if (live.Length == 0) sb.AppendLine(" (nothing in progress)"); + foreach (var e in live) + sb.AppendLine($" name='{e.Name}' received={e.Received:n0} total={e.Total:n0}" + + $" pct={(e.Total > 0 ? 100.0 * e.Received / e.Total : 0):0.0}"); + + sb.AppendLine("\nChromiumProgress.DumpFields():"); + sb.AppendLine(Halo.Widgets.ChromiumProgress.DumpFields()); + + sb.AppendLine("\nPartialFiles.All():"); + foreach (var p in Halo.Widgets.PartialFiles.All()) + sb.AppendLine($" {p}"); + + Halo.Widgets.Downloads.Scan(); + sb.AppendLine($"\nDownloads.Scan() -> {Halo.Widgets.Downloads.Count} item(s), selected=" + + Halo.Widgets.Downloads.SelectedIndex); + foreach (var d in Halo.Widgets.Downloads.Items) + sb.AppendLine($" key='{d.Key}' name='{d.Name}' pct={d.Percent} got={d.Downloaded:n0}" + + $" total={d.Total:n0} noPct={d.NoPct} noBytes={d.NoBytes} pid={d.OwnerPid}" + + $" exe='{d.ExePath}' file='{d.FilePath}'"); + + System.IO.File.WriteAllText(outPath, sb.ToString()); + } + + private static void ProbeIcon(string aumid) + { + var tmp = System.IO.Path.GetTempPath(); + var s = Halo.Notifications.ShellIcon.ForAumid(aumid); + Console.WriteLine($"ShellIcon: {(s == null ? "NULL" : $"{s.Width}x{s.Height} -> probe_shell.png")}"); + s?.Save(System.IO.Path.Combine(tmp, "probe_shell.png")); + var a = Halo.Widgets.AppIcon.ForAumid(aumid); + Console.WriteLine($"AppIcon: {(a == null ? "NULL" : $"{a.Width}x{a.Height} -> probe_app.png")}"); + a?.Save(System.IO.Path.Combine(tmp, "probe_app.png")); + } + + private static void RenderPill(string outPath) + { + var t = new System.Threading.Thread(() => + { + + (string label, string state, string? tool, int agoMin, long ctxUsed, float usage, string? target)[] rows = + { + ("idle — white", "idle", null, 0, 120_000, 0.30f, null), + ("thinking — amber", "working", null, 0, 120_000, 0.30f, null), + ("shell — green", "working", "Bash", 0, 120_000, 0.30f, null), + ("reading — cyan", "working", "Read", 0, 120_000, 0.30f, null), + ("fetching — teal", "working", "WebFetch", 0, 120_000, 0.30f, null), + ("writing — violet", "working", "Edit", 0, 120_000, 0.30f, null), + ("digging — lime", "working", "Grep", 0, 120_000, 0.30f, null), + ("planning — gold", "working", "TodoWrite", 0, 120_000, 0.30f, null), + ("subagent — magenta", "working", "Task", 0, 120_000, 0.30f, null), + ("watching — slate", "working", "Monitor", 0, 120_000, 0.30f, null), + ("your turn — pink", "waiting_input", null, 0, 120_000, 0.30f, null), + ("named: a program", "working", "Bash", 0, 120_000, 0.30f, "dotnet"), + ("named: a file", "working", "Edit", 0, 120_000, 0.30f, "Fx.cs"), + ("named: a host", "working", "WebFetch", 0, 120_000, 0.30f, "learn.microsoft.com"), + ("an mcp server", "working", "mcp__serena__find_symbol", 0, 120_000, 0.30f, null), + ("a tool with no slot", "working", "SomeOtherTool", 0, 120_000, 0.30f, null), + ("thinking, 10 min in", "working", null, 10, 120_000, 0.30f, null), + + ("compacting, just in", "compacting", null, 0, 920_000, 0.30f, null), + ("compacting, 2 min in", "compacting", null, 2, 986_000, 0.30f, null), + ("named, but context 92%", "working", "Edit", 1, 920_000, 0.30f, "Fx.cs"), + ("shell, usage 96%", "working", "Bash", 1, 120_000, 0.96f, null), + ("both, and dragging", "working", "Grep", 15, 950_000, 0.97f, null), + }; + const int pw = 220, ph = 40, gap = 12, labelW = 168, scale = 2; + int width = labelW + pw + 20, height = rows.Length * (ph + gap) + gap; + using var bmp = new System.Drawing.Bitmap(width * scale, height * scale, + System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using var g = System.Drawing.Graphics.FromImage(bmp); + g.Clear(System.Drawing.Color.FromArgb(255, 30, 30, 34)); + g.ScaleTransform(scale, scale); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + using var lf = new System.Drawing.Font("Segoe UI", 11f); + using var lb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(180, 235, 235, 235)); + + var root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "halo-pill-demo"); + System.IO.Directory.CreateDirectory(root); + float y = gap; + int n = 0; + foreach (var (label, state, tool, agoMin, ctxUsed, usage, target) in rows) + { + var now = DateTimeOffset.UtcNow; + + var path = System.IO.Path.Combine(root, $"status-{n++}.json"); + System.IO.File.WriteAllText(path, $$""" + { + "pid": {{System.Environment.ProcessId}}, + "sessionId": "pill", + "state": "{{state}}", + "consolePid": {{System.Environment.ProcessId}}, + "updatedAt": "{{now:o}}", + "startedAt": "{{now.AddMinutes(-agoMin):o}}", + {{(tool is null ? "" : $"\"currentTool\": \"{tool}\",")}} + {{(target is null ? "" : $"\"toolTarget\": \"{target}\",")}} + "session": { "contextUsed": {{ctxUsed}}, "contextMax": 1000000, "promptTokens": 12000 } + } + """); + IWidget w = new ClaudeCodeWidget(new Halo.ClaudeCode.StatusStore(path, + _ => DateTimeOffset.UtcNow.AddMinutes(-agoMin), watchFiles: false), 0, () => { }); + for (int i = 0; i < 60 && !w.IsActive; i++) System.Threading.Thread.Sleep(50); + + Halo.ClaudeCode.Limits.FiveHour = usage; + Halo.ClaudeCode.Limits.FiveHourReset = DateTimeOffset.UtcNow.AddHours(2); + Halo.ClaudeCode.Limits.CreditsUsed = 0; + + using (var warm = new System.Drawing.Bitmap(pw, ph, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb)) + using (var wg = System.Drawing.Graphics.FromImage(warm)) + for (int f = 0; f < 14; f++) w.DrawCollapsed(wg, pw, ph, 1f); + + using var pill = new System.Drawing.Bitmap(pw, ph, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using (var pg = System.Drawing.Graphics.FromImage(pill)) + { + pg.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + + using (var plate = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(236, 16, 16, 18))) + using (var pp = Fx.PillPath(pw, ph, ph / 2f)) + pg.FillPath(plate, pp); + w.DrawCollapsed(pg, pw, ph, 1f); + } + g.DrawString(label, lf, lb, new System.Drawing.RectangleF(12, y + 10, labelW - 20, ph)); + g.DrawImage(pill, labelW, y); + y += ph + gap; + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine(outPath); + }); + t.SetApartmentState(System.Threading.ApartmentState.STA); + t.Start(); + t.Join(); + } + + private static void RenderPin(string outPath) + { + + using var bmp = new System.Drawing.Bitmap(620, 150); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.Clear(System.Drawing.Color.FromArgb(28, 28, 32)); + using var lf = new System.Drawing.Font("Segoe UI", 11f); + using var lb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(170, 235, 235, 235)); + void Cell(float ox, bool pinned, float hover, string label, bool rec = false, float hold = 0f) + { + var st = g.Save(); + g.TranslateTransform(ox, 14); + g.ScaleTransform(3.2f, 3.2f); + Halo.Shell.NotchController.DrawPushpin( + g, new System.Drawing.RectangleF(0, 0, 24, 24), pinned, hover, 1f, rec, hold); + g.Restore(st); + g.DrawString(label, lf, lb, ox - 4, 108); + } + Cell(20, false, 0f, "off"); + Cell(140, true, 0f, "pinned"); + Cell(260, false, 0f, "in capture", rec: true); + Cell(380, true, 0f, "pinned+cap", rec: true); + Cell(500, false, 1f, "mid-hold", hold: 1f); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderGreeting(string outPath) + { + + float[] install = [0.05f, 0.20f, 0.36f, 0.51f, 0.59f, 0.70f, 0.85f, 0.97f]; + float[] login = [0.10f, 0.35f, 0.62f, 0.88f]; + + const int cellW = 620, pad = 18; + + float tall = 40f; + foreach (float t in install) tall += Halo.Shell.GreetingPlan.Install(t).PillH + pad; + foreach (float t in login) tall += Halo.Shell.GreetingPlan.Login(t).PillH + pad; + using var bmp = new System.Drawing.Bitmap(cellW + pad * 2, (int)tall + pad * 4); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; + using (var lg = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, bmp.Width, bmp.Height), + System.Drawing.Color.FromArgb(255, 22, 26, 34), + System.Drawing.Color.FromArgb(255, 46, 30, 40), 60f)) + g.FillRectangle(lg, 0, 0, bmp.Width, bmp.Height); + + using var cap = new System.Drawing.Font("Segoe UI", 12f, System.Drawing.GraphicsUnit.Pixel); + using var capBrush = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(150, 255, 255, 255)); + var notch = new Halo.Shell.LayeredNotch(); + float y = pad; + + g.DrawString("install - the pill opens, writes, clears, then says who it is", cap, capBrush, pad, y); + y += 20f; + foreach (float t in install) + { + var s = Halo.Shell.GreetingPlan.Install(t); + int w = (int)s.PillW, h = (int)s.PillH; + float x = pad + (cellW - w) / 2f; + var st = g.Save(); + g.TranslateTransform(x, y); + notch.DrawShape(g, w, h, (int)s.Radius, 190, glass: false); + var box = Halo.Widgets.Greeting.InkBox(w, h); + Halo.Widgets.Greeting.DrawHello(g, box, s.Written, s.HelloAlpha, + System.Drawing.Color.White, 9f); + if (s.LineAlpha > 0f) + Halo.Widgets.Greeting.DrawLine(g, Halo.Widgets.Greeting.Lines[s.LineIndex], box, + s.LineWritten, s.LineAlpha, System.Drawing.Color.White, 9f); + g.Restore(st); + g.DrawString($"t={t:0.00}", cap, capBrush, pad, y + h - 14f); + y += h + pad; + } + + y += pad; + g.DrawString("login - the same hand, inside a pill that never opens", cap, capBrush, pad, y); + y += 20f; + foreach (float t in login) + { + var s = Halo.Shell.GreetingPlan.Login(t); + int w = (int)s.PillW, h = (int)s.PillH; + float x = pad + (cellW - w) / 2f; + var st = g.Save(); + g.TranslateTransform(x, y); + notch.DrawShape(g, w, h, (int)s.Radius, 190, glass: false); + Halo.Widgets.Greeting.DrawHello(g, Halo.Widgets.Greeting.InkBox(w, h), + s.Written, s.HelloAlpha, System.Drawing.Color.White, 11f); + g.Restore(st); + g.DrawString($"t={t:0.00}", cap, capBrush, pad, y + h - 4f); + y += h + pad; + } + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine($"wrote {outPath}"); + } + + private static void RenderAsk(string outPath) + { + var expires = DateTimeOffset.UtcNow.AddSeconds(20); + var question = new Halo.ClaudeCode.PendingAsk( + "n1", 100, "sess", "AskUserQuestion", null, + + "Fix the frame cadence first, or the icon nobody can miss?", + [new Halo.ClaudeCode.AskOption("Cadence", "the CPU one"), + new Halo.ClaudeCode.AskOption("Icon", "the visible one"), + new Halo.ClaudeCode.AskOption("Measure more first", + "no code yet - sit on the profiler until the regression names itself, " + + "which is the option that costs a day and saves three")], expires); + + var permission = new Halo.ClaudeCode.PendingAsk( + "n2", 100, "sess", "Bash", "git push --force-with-lease origin master", null, + [new Halo.ClaudeCode.AskOption("allow", "run it"), + new Halo.ClaudeCode.AskOption("deny", "skip it")], expires); + + int W = Halo.Widgets.AskBanner.W, pad = 24; + int h1 = Halo.Widgets.AskBanner.Height(question, W); + int h2 = Halo.Widgets.AskBanner.Height(permission, W); + + int[] tints = + [ + Halo.Shell.NotchController.TintAskDesk, + Halo.Shell.NotchController.TintAskApp, + ]; + int total = h1 * tints.Length + h2 + pad * (tints.Length + 2); + using var bmp = new System.Drawing.Bitmap(W + pad * 2, total); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + + using (var lg = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, W + pad * 2, total), + System.Drawing.Color.FromArgb(70, 150, 210), System.Drawing.Color.FromArgb(210, 110, 70), 35f)) + g.FillRectangle(lg, 0, 0, W + pad * 2, total); + + using (var wb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(238, 240, 244))) + using (var kb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(18, 18, 20))) + for (int i = 0; i < tints.Length + 1; i++) + { + g.FillRectangle(wb, 0, pad + i * (h1 + pad) + 92, W + pad * 2, 74); + g.FillRectangle(kb, 0, pad + i * (h1 + pad) + 176, W + pad * 2, 74); + } + + g.TranslateTransform(pad, pad); + for (int i = 0; i < tints.Length; i++) + { + + string? typed = i == 1 ? "\u0633\u0644\u0627\u0645 - profile first" : null; + new Halo.Shell.LayeredNotch().DrawShape(g, W, h1, 26, tints[i], glass: false); + Halo.Widgets.AskBanner.Draw(g, W, h1, 1f, question, hover: 1, tints[i], typed); + g.TranslateTransform(0, h1 + pad); + } + new Halo.Shell.LayeredNotch().DrawShape(g, W, h2, 26, tints[^1], glass: false); + Halo.Widgets.AskBanner.Draw(g, W, h2, 1f, permission, hover: -1, tints[^1]); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderNotif(string outPath) + { + + int W = Halo.Widgets.NotifBanner.W, H = Halo.Widgets.NotifBanner.SummaryH, pad = 24, detailRoom = 340; + using var bmp = new System.Drawing.Bitmap(W + pad * 2, H * 2 + detailRoom + pad * 4); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + using (var lg = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, W + pad * 2, H * 2 + pad * 3), + System.Drawing.Color.FromArgb(70, 150, 210), System.Drawing.Color.FromArgb(210, 110, 70), 35f)) + g.FillRectangle(lg, 0, 0, W + pad * 2, H * 2 + pad * 3); + g.TranslateTransform(pad, pad); + + using var icon = new System.Drawing.Bitmap(64, 64); + using (var ig = System.Drawing.Graphics.FromImage(icon)) + { + ig.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + ig.Clear(System.Drawing.Color.Transparent); + using var b = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(255, 40, 150, 235)); + ig.FillEllipse(b, 2, 2, 60, 60); + } + + using var shot = new System.Drawing.Bitmap(1920, 1080); + using (var sgg = System.Drawing.Graphics.FromImage(shot)) + { + using var lg2 = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, 1920, 1080), + System.Drawing.Color.FromArgb(30, 30, 40), System.Drawing.Color.FromArgb(90, 60, 120), 45f); + sgg.FillRectangle(lg2, 0, 0, 1920, 1080); + using var wf = new System.Drawing.Font("Segoe UI", 120f); + sgg.DrawString("desktop", wf, System.Drawing.Brushes.White, 500, 450); + } + new Halo.Shell.LayeredNotch().DrawShape(g, W, H, 26, 245, glass: false); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + var n = new Halo.Notifications.NotifItem + { + Icon = icon, + + App = Halo.Notifications.NotifItem.ScreenshotApp, + Title = Halo.Notifications.NotifItem.ScreenshotTitle, + + Body = "Saved to the clipboard. Click the banner to edit it, or press Ctrl+V in any " + + "app to paste it straight in.", + Code = "482913", + Preview = shot, + }; + Halo.Widgets.NotifBanner.Draw(g, W, H, 1f, n, 0f, false); + + g.TranslateTransform(0, H + pad); + new Halo.Shell.LayeredNotch().DrawShape(g, W, H, 26, 245, glass: false); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + Halo.Widgets.NotifBanner.Draw(g, W, H, 1f, new Halo.Notifications.NotifItem + { + Icon = icon, + App = "Telegram", + Title = "\u0633\u0644\u0627\u0645", + Body = "\u0628\u0632\u0646 \u0628\u0631\u06cc\u0645", + + Stacked = 5, + }, 0f, false); + + var mixed = new Halo.Notifications.NotifItem + { + Icon = icon, + App = "ChatGPT", + Title = "\u0633\u0627\u062e\u062a \u067e\u0646\u0644 \u0645\u062f\u06cc\u0631\u06cc\u062a \u062f\u0633\u062a\u0631\u0633\u06cc Halo", + Body = "\u0627\u0648\u06a9\u06cc\u060c \u0622\u062e\u0631\u06cc\u0646 \u0627\u0646\u062a\u062e\u0627\u0628: \u0645\u0646\u0648 \u0633\u0645\u062a \u0686\u067e\u060c \u0645\u062d\u062a\u0648\u0627\u06cc \u062a\u0646\u0638\u06cc\u0645\u0627\u062a \u0633\u0645\u062a \u0631\u0627\u0633\u062a. " + + "Halo | Media | [ Enabled ] | General | Appearance | Playback | " + + "Auto-show on track change | FEATURES | Include VLC | Show collapsed progress | " + + "Downloads | File Tray | Bluetooth | Follow active player | Notifications | " + + "Idle timeout 15 sec | Claude", + }; + int dh = Math.Min(detailRoom, Halo.Widgets.NotifBanner.DetailHeight(mixed)); + g.TranslateTransform(0, H + pad); + new Halo.Shell.LayeredNotch().DrawShape(g, W, dh, 26, 245, glass: false); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + Halo.Widgets.NotifBanner.Draw(g, W, dh, 1f, mixed, 1f, true); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderBar(string outPath, string? accentHex, string? fracStr) + { + const int W = 220, H = 40, Zoom = 2, Rows = 7, Pad = 8; + var accent = System.Drawing.Color.FromArgb(228, 168, 64); + float frac = fracStr != null + ? float.Parse(fracStr, System.Globalization.CultureInfo.InvariantCulture) : 0.42f; + if (accentHex != null) + accent = System.Drawing.Color.FromArgb( + (int)(uint.Parse(accentHex, System.Globalization.NumberStyles.HexNumber) | 0xFF000000)); + using var bmp = new System.Drawing.Bitmap(W * Zoom + Pad * 2 + 150, + (H * Zoom + Pad) * Rows + Pad, System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using var g = System.Drawing.Graphics.FromImage(bmp); + g.Clear(System.Drawing.Color.FromArgb(255, 18, 18, 21)); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using var label = new System.Drawing.Font("Segoe UI", 13f, System.Drawing.GraphicsUnit.Pixel); + using var lb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(225, 225, 230)); + + for (int r = 0; r < Rows; r++) + { + bool paused = r == Rows - 1; + g.DrawString(paused ? "paused" : $"playing +{r * 430}ms", label, lb, 8, Pad + r * (H * Zoom + Pad) + 26); + using var pill = new System.Drawing.Bitmap(W, H, System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using (var pg = System.Drawing.Graphics.FromImage(pill)) + { + pg.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using (var back = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(255, 12, 12, 14))) + using (var pp = Halo.Widgets.Fx.PillPath(W, H, H / 2f)) + pg.FillPath(back, pp); + + Halo.Widgets.Fx.PillBar(pg, W, H, 1f, frac, accent, 0.5f, alive: !paused); + } + g.DrawImage(pill, new System.Drawing.Rectangle(150, Pad + r * (H * Zoom + Pad), W * Zoom, H * Zoom)); + if (!paused) System.Threading.Thread.Sleep(430); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine(outPath); + } + + private static void ProbeSeek(double secs, int count) + { + var sessions = new Halo.Widgets.MediaSessions(); + for (int i = 0; i < 40 && sessions.Session(0) is null; i++) System.Threading.Thread.Sleep(100); + var s = sessions.Session(0); + if (s is null) { Console.WriteLine("no session"); return; } + + var widget = new Halo.Widgets.MediaWidget(sessions, 0); + + using var scratch = new System.Drawing.Bitmap(220, 40, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using var sg = System.Drawing.Graphics.FromImage(scratch); + void Pump() { try { widget.DrawCollapsed(sg, 220, 40, 1f); } catch { } } + for (int i = 0; i < 20 && widget.RingProgress < 0f; i++) { Pump(); System.Threading.Thread.Sleep(100); } + + Console.WriteLine($"before pos={s.GetTimelineProperties().Position}"); + + for (int n = 1; n <= count; n++) + { + widget.SeekByForProbe((int)secs); + + int gap = int.TryParse(Environment.GetEnvironmentVariable("HALO_SEEK_GAP"), out var gv) ? gv : 120; + for (int k = 0; k < Math.Max(1, gap / 100); k++) { Pump(); System.Threading.Thread.Sleep(100); } + Console.WriteLine($"tap {n} player={s.GetTimelineProperties().Position}" + + $" widget={widget.PositionForProbe} ring={widget.RingProgress:0.0000}"); + } + for (int i = 1; i <= 16; i++) + { + System.Threading.Thread.Sleep(400); + Pump(); + var now = s.GetTimelineProperties(); + Console.WriteLine($" +{i * 400,4}ms pos={now.Position} updated={now.LastUpdatedTime:HH:mm:ss.fff}" + + $" widget.RingProgress={widget.RingProgress:0.0000}"); + } + } + + private static void ProbeTimeline() + { + var sessions = new Halo.Widgets.MediaSessions(); + + for (int i = 0; i < 40 && sessions.Session(0) is null; i++) System.Threading.Thread.Sleep(100); + var slots = new Halo.Widgets.MediaWidget[Halo.Widgets.MediaSessions.MaxSlots]; + for (int s = 0; s < slots.Length; s++) slots[s] = new Halo.Widgets.MediaWidget(sessions, s); + for (int i = 0; i < 30; i++) + { + for (int s = 0; s < slots.Length; s++) + { + if (sessions.Session(s) is null) continue; + Console.WriteLine($"{i * 0.5,5:0.0}s [{s}] {slots[s].ProbeLine() ?? "session hooked, no title yet"}"); + } + System.Threading.Thread.Sleep(500); + } + } + + private static void ProbeMedia() + { + var sessions = new Halo.Widgets.MediaSessions(); + for (int i = 0; i < 40 && sessions.Session(0) is null; i++) System.Threading.Thread.Sleep(100); + + for (int slot = 0; slot < Halo.Widgets.MediaSessions.MaxSlots; slot++) + { + var s = sessions.Session(slot); + if (s is null) { Console.WriteLine($"slot {slot} (empty)"); continue; } + string? aumid = null; + try { aumid = s.SourceAppUserModelId; } catch { } + Console.WriteLine($"slot {slot} app='{sessions.SlotApp(slot)}' aumid='{aumid}'"); + + bool thumb = false; + try + { + var props = s.TryGetMediaPropertiesAsync().AsTask().GetAwaiter().GetResult(); + thumb = props?.Thumbnail != null; + Console.WriteLine($" title='{props?.Title}' thumbnail={(thumb ? "yes" : "NONE")}"); + } + catch (Exception ex) { Console.WriteLine(" properties failed: " + ex.Message); } + + try + { + var tl = s.GetTimelineProperties(); + var pb = s.GetPlaybackInfo(); + Console.WriteLine($" pos={tl.Position} start={tl.StartTime} end={tl.EndTime}"); + Console.WriteLine($" minSeek={tl.MinSeekTime} maxSeek={tl.MaxSeekTime}" + + $" lastUpdated={tl.LastUpdatedTime:HH:mm:ss}"); + Console.WriteLine($" canSeek={pb.Controls.IsPlaybackPositionEnabled}" + + $" canRate={pb.Controls.IsPlaybackRateEnabled} rate={pb.PlaybackRate}" + + $" state={pb.PlaybackStatus} type={pb.PlaybackType}"); + } + catch (Exception ex) { Console.WriteLine(" timeline failed: " + ex.Message); } + + var shell = aumid is null ? null : Halo.Notifications.ShellIcon.ForAumid(aumid); + var exe = Halo.Widgets.AppIcon.ForAumid(aumid); + var chain = Halo.Widgets.AppIcon.ForSessionApp(aumid); + Console.WriteLine($" ShellIcon={(shell is null ? "NULL" : $"{shell.Width}x{shell.Height}")}" + + $" AppIcon={(exe is null ? "NULL" : $"{exe.Width}x{exe.Height}")}" + + $" chain={(chain is null ? "NULL → the glyph fallback draws" : $"{chain.Width}x{chain.Height}")}"); + } + } + + private static void RenderGlyphs(string outPath) + { + (string glyph, string name)[] rows = + { + ("", "media art fallback"), + ("", "media (menu)"), + ("", "agent fallback"), + ("", "download"), + ("", "robot / generic agent"), + ("", "bluetooth"), + ("", "file tray"), + }; + const int Tile = 22, Zoom = 6, Pad = 10, LabelW = 190; + int cell = Tile * Zoom; + int width = LabelW + Pad * 3 + cell * 2, height = Pad + rows.Length * (cell + Pad); + + using var bmp = new System.Drawing.Bitmap(width, height, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using var g = System.Drawing.Graphics.FromImage(bmp); + g.Clear(System.Drawing.Color.FromArgb(255, 24, 24, 28)); + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + using var label = new System.Drawing.Font("Segoe UI", 15f, System.Drawing.GraphicsUnit.Pixel); + using var head = new System.Drawing.Font("Segoe UI Semibold", 14f, System.Drawing.GraphicsUnit.Pixel); + using var lb = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(235, 235, 240)); + using var white = new System.Drawing.SolidBrush(System.Drawing.Color.White); + using var tileBrush = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(40, 255, 255, 255)); + using var cross = new System.Drawing.Pen(System.Drawing.Color.FromArgb(120, 255, 90, 140), 1f); + + int y = Pad; + for (int i = 0; i < rows.Length; i++) + { + var (glyph, name) = rows[i]; + g.DrawString(name, label, lb, new System.Drawing.PointF(Pad, y + cell / 2f - 10f)); + if (i == 0) + { + g.DrawString("StringFormat", head, lb, new System.Drawing.PointF(LabelW + Pad * 2, 2f)); + g.DrawString("ink", head, lb, new System.Drawing.PointF(LabelW + Pad * 3 + cell, 2f)); + } + + for (int col = 0; col < 2; col++) + { + float tx = LabelW + Pad * 2 + col * (cell + Pad); + var tile = new System.Drawing.RectangleF(tx, y, cell, cell); + + using (var p = Halo.Widgets.Fx.Rounded(tile, 14f * Zoom)) g.FillPath(tileBrush, p); + g.DrawLine(cross, tx, y + cell / 2f, tx + cell, y + cell / 2f); + g.DrawLine(cross, tx + cell / 2f, y, tx + cell / 2f, y + cell); + + using var gf = new System.Drawing.Font("Segoe Fluent Icons", Tile * 0.5f * Zoom, + System.Drawing.GraphicsUnit.Pixel); + if (col == 0) + { + using var sf = new System.Drawing.StringFormat(System.Drawing.StringFormat.GenericTypographic) + { + Alignment = System.Drawing.StringAlignment.Center, + LineAlignment = System.Drawing.StringAlignment.Center, + }; + g.DrawString(glyph, gf, white, tile, sf); + } + else Halo.Widgets.Fx.GlyphCentred(g, tile, glyph, gf, white); + } + y += cell + Pad; + } + + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + Console.WriteLine(outPath); + } + + private static void RenderCopy(string outPath) + { + int W = Halo.Widgets.NotifBanner.W, H = Halo.Widgets.NotifBanner.SummaryH; + const int Zoom = 4, Pad = 6; + + var states = new[] { false, true }; + var shots = new System.Drawing.Bitmap[states.Length]; + var rects = new System.Drawing.RectangleF[states.Length]; + + for (int s = 0; s < states.Length; s++) + { + var n = new Halo.Notifications.NotifItem + { + App = "Aurora", Title = "Verify your sign-in", + Body = "Your verification code is 482913. It expires in 10 minutes.", + Code = "482913", Copied = states[s], + }; + rects[s] = Halo.Widgets.NotifBanner.CopyRect(n, W); + var full = new System.Drawing.Bitmap(W, H, System.Drawing.Imaging.PixelFormat.Format32bppPArgb); + using (var g = System.Drawing.Graphics.FromImage(full)) + { + g.Clear(System.Drawing.Color.FromArgb(255, 18, 18, 22)); + Halo.Widgets.NotifBanner.Draw(g, W, H, 1f, n, 0f, false); + } + shots[s] = full; + } + + int cw = (int)Math.Ceiling(rects[0].Width) + Pad * 2; + int ch = (int)Math.Ceiling(rects[0].Height) + Pad * 2; + using var bmp = new System.Drawing.Bitmap(cw * Zoom, ch * Zoom * states.Length); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.Half; + g.Clear(System.Drawing.Color.FromArgb(255, 12, 12, 14)); + for (int s = 0; s < states.Length; s++) + { + var r = rects[s]; + var src = new System.Drawing.Rectangle((int)r.X - Pad, (int)r.Y - Pad, cw, ch); + var dst = new System.Drawing.Rectangle(0, s * ch * Zoom, cw * Zoom, ch * Zoom); + g.DrawImage(shots[s], dst, src, System.Drawing.GraphicsUnit.Pixel); + + float mid = dst.Y + (Pad + r.Height / 2f) * Zoom; + using var guide = new System.Drawing.Pen(System.Drawing.Color.FromArgb(150, 255, 70, 70), 1f); + g.DrawLine(guide, dst.X, mid, dst.Right, mid); + } + } + foreach (var s in shots) s.Dispose(); + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderLocal(string outPath) + { + int W = Halo.Widgets.NotifBanner.W, H = Halo.Widgets.NotifBanner.SummaryH, pad = 20; + using var shot = new System.Drawing.Bitmap(1920, 1080); + using (var sg = System.Drawing.Graphics.FromImage(shot)) + { + using var lg = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, 1920, 1080), + System.Drawing.Color.FromArgb(240, 245, 250), System.Drawing.Color.FromArgb(150, 190, 235), 45f); + sg.FillRectangle(lg, 0, 0, 1920, 1080); + using var wf = new System.Drawing.Font("Segoe UI", 130f); + sg.DrawString("desktop", wf, System.Drawing.Brushes.DimGray, 430, 440); + } + + var notices = Halo.Shell.NotchController.SampleLocalNotices(shot); + using var bmp = new System.Drawing.Bitmap(W + pad * 2, notices.Length * (H + pad) + pad); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + using (var lg = new System.Drawing.Drawing2D.LinearGradientBrush( + new System.Drawing.Rectangle(0, 0, bmp.Width, bmp.Height), + System.Drawing.Color.FromArgb(60, 140, 200), System.Drawing.Color.FromArgb(200, 100, 60), 35f)) + g.FillRectangle(lg, 0, 0, bmp.Width, bmp.Height); + + var notch = new Halo.Shell.LayeredNotch(); + for (int i = 0; i < notices.Length; i++) + { + var state = g.Save(); + g.TranslateTransform(pad, pad + i * (H + pad)); + notch.DrawShape(g, W, H, 26, 245, glass: false); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + Halo.Widgets.NotifBanner.Draw(g, W, H, 1f, notices[i], 0f, false); + using (var guide = new System.Drawing.Pen(System.Drawing.Color.FromArgb(90, 255, 80, 80), 1f)) + g.DrawLine(guide, 0, H / 2f, W, H / 2f); + g.Restore(state); + } + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderBadges(string outPath) + { + var badges = Halo.Shell.Badges.All(); + using var bmp = new System.Drawing.Bitmap(badges.Length * 84 + 20, 104); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.Clear(System.Drawing.Color.FromArgb(28, 28, 32)); + for (int i = 0; i < badges.Length; i++) + g.DrawImage(badges[i], 10 + i * 84, 20, 64, 64); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static IWidget Tray() + { + var tray = new FileTray(); + FileTray.SetDragActive(true); + return tray; + } + + private static System.Threading.Mutex? _instance; + private static Halo.Shell.TrayIcon? _tray; + + private static void OpenSettingsPanel() + { + try + { + string exe = System.IO.Path.Combine(AppContext.BaseDirectory, "Halo.Settings.exe"); + if (!System.IO.File.Exists(exe)) return; + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); + } + catch { } + } + + internal static void OpenSettings() => OpenSettingsPanel(); + + private static void Teardown() + { + try { _tray?.Dispose(); _tray = null; } catch { } + try { _instance?.ReleaseMutex(); } catch { } + try { _instance?.Dispose(); _instance = null; } catch { } + } + + internal static void Quit() + { + Teardown(); + Environment.Exit(0); + } + + internal static void Restart() + { + string exe = Environment.ProcessPath ?? ""; + Teardown(); + try + { + if (exe.Length > 0) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); + } + catch { } + Environment.Exit(0); + } + + private static void RenderFluent(string outPath, string startOrList, string countArg) + { + var codes = new System.Collections.Generic.List(); + if (startOrList.Contains(',')) + { + foreach (var part in startOrList.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (int.TryParse(part.Trim(), System.Globalization.NumberStyles.HexNumber, null, out var c)) codes.Add(c); + } + else if (int.TryParse(startOrList, System.Globalization.NumberStyles.HexNumber, null, out var start)) + { + int n = int.TryParse(countArg, out var parsed) ? parsed : 256; + for (int i = 0; i < n; i++) codes.Add(start + i); + } + if (codes.Count == 0) return; + + const int Cell = 92, Cols = 12, Label = 18; + int rows = (codes.Count + Cols - 1) / Cols; + using var bmp = new System.Drawing.Bitmap(Cols * Cell, rows * (Cell + Label) + 10); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.Clear(System.Drawing.Color.FromArgb(24, 24, 28)); + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + using var glyphFont = new System.Drawing.Font("Segoe Fluent Icons", 46f, System.Drawing.GraphicsUnit.Pixel); + using var labelFont = new System.Drawing.Font("Consolas", 14f, System.Drawing.GraphicsUnit.Pixel); + using var ink = new System.Drawing.SolidBrush(System.Drawing.Color.White); + using var dim = new System.Drawing.SolidBrush(System.Drawing.Color.FromArgb(150, 160, 170)); + using var sf = new System.Drawing.StringFormat + { + Alignment = System.Drawing.StringAlignment.Center, + LineAlignment = System.Drawing.StringAlignment.Center, + }; + for (int i = 0; i < codes.Count; i++) + { + float x = i % Cols * Cell, y = i / Cols * (Cell + Label); + g.DrawString(((char)codes[i]).ToString(), glyphFont, ink, + new System.Drawing.RectangleF(x, y, Cell, Cell), sf); + g.DrawString(codes[i].ToString("X4"), labelFont, dim, + new System.Drawing.RectangleF(x, y + Cell - 4, Cell, Label), sf); + } + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private static void RenderWidget(string outPath, string which, int scale = 1, string[]? args = null) + { + var t = new System.Threading.Thread(() => + { + if (which == "download") + { + Halo.Widgets.Downloads.Name = "Source.Code.2011.1080p.BluRay.10bit.x265.Farsi.Dubbed.mkv"; + Halo.Widgets.Downloads.Percent = 36; + + Halo.Widgets.Downloads.ExePath = new[] + { + @"C:\Program Files\Google\Chrome\Application\chrome.exe", + @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + }.FirstOrDefault(System.IO.File.Exists) ?? @"C:\Windows\explorer.exe"; + Halo.Widgets.Downloads.Hwnd = new IntPtr(1); + } + if (which == "download-install") + { + Halo.Widgets.Downloads.Name = "Microsoft Store"; + Halo.Widgets.Downloads.ExePath = "Microsoft.WindowsStore_8wekyb3d8bbwe!App"; + Halo.Widgets.Downloads.IsStore = true; + Halo.Widgets.Downloads.Installing = true; + which = "download"; + } + + string demoRoot = ""; + + string codexRoot = ""; + if (which == "codex-demo") + { + codexRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "halo-codex-demo"); + System.IO.Directory.CreateDirectory(codexRoot); + var cnow = DateTimeOffset.UtcNow; + System.IO.File.WriteAllText(System.IO.Path.Combine(codexRoot, "cli.json"), $$""" + { + "pid": {{System.Environment.ProcessId}}, + "source": "cli", + "state": "working", + "consolePid": {{System.Environment.ProcessId}}, + "updatedAt": "{{cnow:o}}", + "startedAt": "{{cnow.AddMinutes(-4):o}}", + "currentTool": "apply_patch", + "contextUsed": 712000, + "contextMax": 1000000, + "primaryLimit": { "usedPercent": 61, "windowMinutes": 300, "resetsAt": "{{cnow.AddHours(1).AddMinutes(52):o}}" }, + "secondaryLimit": { "usedPercent": 34, "windowMinutes": 10080, "resetsAt": "{{cnow.AddDays(4):o}}" } + } + """); + } + bool demo = which is "claude-demo" or "claude-idle" or "claude-hot"; + + bool hot = which == "claude-hot"; + if (demo) + { + demoRoot = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "halo-claude-demo"); + System.IO.Directory.CreateDirectory(demoRoot); + var now = DateTimeOffset.UtcNow; + + var demoState = which == "claude-idle" ? "idle" : "working"; + long ctxUsed = hot ? 862_000 : 341_000; + System.IO.File.WriteAllText(System.IO.Path.Combine(demoRoot, "status.json"), $$""" + { + "pid": {{System.Environment.ProcessId}}, + "sessionId": "demo", + "cwd": "C:\\Projects\\halo", + "state": "{{demoState}}", + "consolePid": {{System.Environment.ProcessId}}, + "updatedAt": "{{now:o}}", + "startedAt": "{{now.AddMinutes(-12):o}}", + "currentTool": "Edit", + "session": { "contextUsed": {{ctxUsed}}, "contextMax": 1000000, "promptTokens": 48200 } + } + """); + Halo.ClaudeCode.Limits.FiveHour = hot ? 0.93f : 0.42f; + Halo.ClaudeCode.Limits.FiveHourReset = now.AddHours(2).AddMinutes(48); + } + + IWidget w = which switch + { + "claude-demo" or "claude-idle" or "claude-hot" => new ClaudeCodeWidget( + new Halo.ClaudeCode.StatusStore(System.IO.Path.Combine(demoRoot, "status.json"), + _ => DateTimeOffset.UtcNow.AddMinutes(-12), watchFiles: false), 0, () => { }), + "claude" => new ClaudeCodeWidget(new Halo.ClaudeCode.StatusStore(), 0, () => { }), + "codex-demo" => new CodexWidget( + new Halo.Codex.CodexStatusStore(codexRoot, codexRoot, _ => true, watchFiles: false), + Halo.Codex.CodexSurface.Cli, () => { }, observeLimits: _ => { }), + "codex" => new CodexWidget(new Halo.Codex.CodexStatusStore(), Halo.Codex.CodexSurface.Cli, () => { }), + "download" => new DownloadWidget(), + + "tray" => Tray(), + _ => new MediaWidget(new MediaSessions(), 0), + }; + for (int i = 0; i < 100 && !w.IsActive; i++) + System.Threading.Thread.Sleep(100); + scale = Math.Clamp(scale, 1, 6); + if (demo || which == "codex-demo") + { + + using (var warm = new System.Drawing.Bitmap(560, 220)) + using (var wg = System.Drawing.Graphics.FromImage(warm)) + w.DrawContent(wg, 560, 220, 1f); + } + + if (which is "claude" or "codex" or "codex-demo" || demo) + System.Threading.Thread.Sleep(8000); + + if (demo || which == "codex-demo") + { + Halo.ClaudeCode.Limits.FiveHour = hot ? 0.93f : 0.42f; + Halo.ClaudeCode.Limits.FiveHourReset = DateTimeOffset.UtcNow.AddHours(2).AddMinutes(48); + Halo.ClaudeCode.Limits.Week = hot ? 0.78f : 0.61f; + Halo.ClaudeCode.Limits.WeekReset = DateTimeOffset.UtcNow.AddDays(3).AddHours(5); + Halo.ClaudeCode.Limits.CreditsUsed = 0; + Halo.ClaudeCode.Limits.LastSuccess = DateTime.UtcNow.AddMinutes(-2); + + const string demoIp = "203.0.113.24"; + Halo.ClaudeCode.IpCountry.Ip = demoIp; + Halo.ClaudeCode.IpCountry.ApiIp = null; + Halo.ClaudeCode.IpCountry.Cc = "NL"; + Halo.ClaudeCode.IpCountry.Isp = "Example ISP"; + Halo.ClaudeCode.IpCountry.Asn = "AS64496"; + try + { + using var flagHttp = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(8) }; + Halo.ClaudeCode.IpCountry.Flag = new System.Drawing.Bitmap(new System.IO.MemoryStream( + flagHttp.GetByteArrayAsync("https://flagcdn.com/w320/nl.png").Result)); + } + catch { } + Halo.ClaudeCode.IpRep.ForIp = demoIp; + Halo.ClaudeCode.IpRep.Verdict = "residential"; + Halo.ClaudeCode.IpRep.Abuse = null; + Halo.ClaudeCode.IpRep.Sev = 0; + Halo.ClaudeCode.IpRep.Tor = false; + Halo.ClaudeCode.IpRep.Abuser = false; + Halo.ClaudeCode.IpRep.Bogon = false; + Halo.ClaudeCode.IpRep.Vpn = false; + Halo.ClaudeCode.IpRep.Proxy = false; + Halo.ClaudeCode.IpRep.Datacenter = false; + Halo.ClaudeCode.DnsLeak.ForIp = demoIp; + Halo.ClaudeCode.DnsLeak.Running = false; + Halo.ClaudeCode.DnsLeak.Done = true; + Halo.ClaudeCode.DnsLeak.Resolvers = 3; + Halo.ClaudeCode.DnsLeak.Where = "NL"; + Halo.ClaudeCode.DnsLeak.Leaking = false; + } + + if (args is { Length: > 4 } && args[4].Contains(',')) + { + var xy = args[4].Split(','); + if (float.TryParse(xy[0], out float mx) && float.TryParse(xy[1], out float my)) + { + Halo.Widgets.WidgetInput.Mouse = new System.Drawing.PointF(mx, my); + Halo.Widgets.WidgetInput.Over = true; + } + } + + if (Environment.GetEnvironmentVariable("HALO_RENDER_NET") == "1") + { + Halo.ClaudeCode.IpCountry.Poke(); + System.Threading.Thread.Sleep(5000); + string? exit = Halo.ClaudeCode.IpCountry.Split + ? Halo.ClaudeCode.IpCountry.ApiIp : Halo.ClaudeCode.IpCountry.Ip; + Halo.ClaudeCode.IpRep.Want(exit); + Halo.ClaudeCode.DnsLeak.Want(exit, + Halo.ClaudeCode.IpCountry.Split ? Halo.ClaudeCode.IpCountry.ApiCc : Halo.ClaudeCode.IpCountry.Cc); + for (int i = 0; i < 40 && !Halo.ClaudeCode.DnsLeak.Done; i++) System.Threading.Thread.Sleep(500); + } + + using (var warm = new System.Drawing.Bitmap(560, 220, + System.Drawing.Imaging.PixelFormat.Format32bppPArgb)) + using (var wg = System.Drawing.Graphics.FromImage(warm)) + for (int f = 0; f < 45; f++) + { + wg.Clear(System.Drawing.Color.FromArgb(20, 20, 22)); + try { w.DrawContent(wg, 560, 220, 1f); } catch { } + System.Threading.Thread.Sleep(12); + } + + using var bmp = new System.Drawing.Bitmap(560 * scale, 220 * scale); + using (var g = System.Drawing.Graphics.FromImage(bmp)) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; + g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; + g.Clear(System.Drawing.Color.FromArgb(20, 20, 22)); + g.ScaleTransform(scale, scale); + w.DrawContent(g, 560, 220, 1f); + } + bmp.Save(outPath, System.Drawing.Imaging.ImageFormat.Png); + }); + t.SetApartmentState(System.Threading.ApartmentState.MTA); + t.Start(); + t.Join(); + } +} diff --git a/src/Halo.App/Properties/AssemblyInfo.cs b/src/Halo.App/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..48e24d8 --- /dev/null +++ b/src/Halo.App/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Halo.Tests")] diff --git a/src/Halo.App/Settings/FeatureCatalog.cs b/src/Halo.App/Settings/FeatureCatalog.cs new file mode 100644 index 0000000..383895d --- /dev/null +++ b/src/Halo.App/Settings/FeatureCatalog.cs @@ -0,0 +1,36 @@ +using System; + +namespace Halo.Settings; + +internal enum FeatureId +{ + Media, + Downloads, + FileTray, + Bluetooth, + Notifications, + ClaudeCode, + Codex, + GenericAgents, +} + +internal sealed record FeatureDefinition(FeatureId Id, string Key, string Label, string Description); + +internal static class FeatureCatalog +{ + + internal static readonly FeatureDefinition[] All = + [ + new(FeatureId.Media, "media", "Media", "Playback sessions and classic VLC controls"), + new(FeatureId.Downloads, "downloads", "Downloads", "Browser, store, game and app progress"), + new(FeatureId.FileTray, "fileTray", "File Tray", "Drag-and-drop shelf and clipboard images"), + new(FeatureId.Bluetooth, "bluetooth", "Bluetooth", "Connection and battery takeovers"), + new(FeatureId.Notifications, "notifications", "Notifications", "Mirror Windows toast banners"), + new(FeatureId.ClaudeCode, "claudeCode", "Claude Code", "Claude sessions, limits and controls"), + new(FeatureId.Codex, "codex", "Codex", "Codex Desktop and CLI sessions"), + new(FeatureId.GenericAgents, "genericAgents", "Other agents", "Generic agent status files"), + ]; + + internal static FeatureDefinition For(FeatureId id) + => Array.Find(All, item => item.Id == id)!; +} diff --git a/src/Halo.App/Settings/SettingsFile.cs b/src/Halo.App/Settings/SettingsFile.cs new file mode 100644 index 0000000..dc60993 --- /dev/null +++ b/src/Halo.App/Settings/SettingsFile.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Halo.Settings; + +internal sealed class SettingsFile +{ + internal const int CurrentVersion = 1; + + private readonly Dictionary _values; + + internal SettingsFile(IDictionary? values = null) + => _values = values is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(values, StringComparer.OrdinalIgnoreCase); + + internal static SettingsFile Empty => new(); + + internal IReadOnlyDictionary Values => _values; + + internal string Text(string key, string fallback) + => _values.TryGetValue(key, out var v) && v.Length > 0 ? v : fallback; + + internal bool Bool(string key, bool fallback) + => _values.TryGetValue(key, out var v) + ? v.Equals("on", StringComparison.OrdinalIgnoreCase) || v.Equals("true", StringComparison.OrdinalIgnoreCase) + : fallback; + + internal float Number(string key, float fallback) + { + if (!_values.TryGetValue(key, out var v) || v.Length == 0) return fallback; + var span = v.AsSpan().Trim().TrimEnd('%').Trim(); + return float.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var n) ? n : fallback; + } + + internal SettingsFile With(string key, string value) + { + var next = new Dictionary(_values, StringComparer.OrdinalIgnoreCase) { [key] = value }; + return new SettingsFile(next); + } + + internal string ToJson() + { + var values = new JsonObject(); + foreach (var (key, value) in _values) values[key] = value; + return new JsonObject { ["version"] = CurrentVersion, ["values"] = values } + .ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + internal static SettingsFile FromJson(string? json) + { + try + { + if (string.IsNullOrWhiteSpace(json)) return Empty; + if (JsonNode.Parse(json) is not JsonObject root) return Empty; + if (root["values"] is not JsonObject values) return Empty; + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, node) in values) + { + if (node is null) continue; + string? text = node is JsonValue value && value.TryGetValue(out var s) + ? s : node.ToJsonString().Trim('"'); + if (!string.IsNullOrEmpty(text)) map[key] = text; + } + return new SettingsFile(map); + } + catch { return Empty; } + } + + internal static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "settings.json"); + + internal bool Save(string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + string tmp = path + ".tmp"; + File.WriteAllText(tmp, ToJson()); + File.Move(tmp, path, overwrite: true); + return true; + } + catch { return false; } + } + + internal static SettingsFile Read(string path) + { + try { return File.Exists(path) ? FromJson(File.ReadAllText(path)) : Empty; } + catch { return Empty; } + } +} + +internal static class SettingsKeys +{ + internal const string StartWithWindows = "general.startup"; + internal const string OverFullscreen = "general.fullscreen"; + internal const string InCaptures = "general.capture"; + internal const string FollowFocus = "general.follow"; + internal const string Scale = "appearance.scale"; + internal const string Glass = "appearance.glass"; + internal const string Motion = "appearance.motion"; + + internal static string Feature(FeatureId id) => "feature." + FeatureCatalog.For(id).Key; +} diff --git a/src/Halo.App/Settings/SettingsStore.cs b/src/Halo.App/Settings/SettingsStore.cs new file mode 100644 index 0000000..d233ae0 --- /dev/null +++ b/src/Halo.App/Settings/SettingsStore.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Threading; + +namespace Halo.Settings; + +internal sealed class SettingsStore : IDisposable +{ + private readonly string _path; + private readonly FileSystemWatcher? _watcher; + private readonly Timer? _poll; + private readonly object _gate = new(); + private SettingsFile _current; + private int _version; + private bool _disposed; + + internal SettingsStore(string? path = null, bool watch = true) + { + _path = path ?? SettingsFile.DefaultPath; + _current = SettingsFile.Read(_path); + if (!watch) return; + try + { + var dir = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(dir); + _watcher = new FileSystemWatcher(dir, Path.GetFileName(_path)) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + EnableRaisingEvents = true, + }; + _watcher.Changed += (_, _) => Reload(); + _watcher.Created += (_, _) => Reload(); + _watcher.Deleted += (_, _) => Reload(); + _watcher.Renamed += (_, _) => Reload(); + _poll = new Timer(_ => Reload(), null, 1000, 1000); + } + catch { } + } + + internal SettingsFile Current + { + get { lock (_gate) return _current; } + } + + internal static SettingsStore? Shared { get; set; } + + internal static bool On(string key, bool fallback = true) + => Shared?.Current.Bool(key, fallback) ?? fallback; + + internal static int Percent(string key, int fallback) + { + try + { + string text = (Shared?.Current.Text(key, "") ?? "").TrimEnd('%', ' '); + return int.TryParse(text, out var value) && value is >= 0 and <= 100 ? value : fallback; + } + catch { return fallback; } + } + + internal int Version => Volatile.Read(ref _version); + + internal event Action? Changed; + + internal bool Enabled(FeatureId id) => Current.Bool(SettingsKeys.Feature(id), true); + + internal bool Set(string key, string value) + { + SettingsFile next; + lock (_gate) + { + if (_current.Text(key, "") == value) return false; + next = _current.With(key, value); + if (!next.Save(_path)) return false; + _current = next; + } + Interlocked.Increment(ref _version); + Changed?.Invoke(next); + return true; + } + + private void Reload() + { + var next = SettingsFile.Read(_path); + lock (_gate) + { + if (Same(_current, next)) return; + _current = next; + } + Interlocked.Increment(ref _version); + try { Changed?.Invoke(next); } catch { } + } + + private static bool Same(SettingsFile a, SettingsFile b) + { + if (a.Values.Count != b.Values.Count) return false; + foreach (var (key, value) in a.Values) + if (!b.Values.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal)) + return false; + return true; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try { _watcher?.Dispose(); } catch { } + try { _poll?.Dispose(); } catch { } + } +} diff --git a/src/Halo.App/Shell/Almanac.cs b/src/Halo.App/Shell/Almanac.cs new file mode 100644 index 0000000..fdcf1a1 --- /dev/null +++ b/src/Halo.App/Shell/Almanac.cs @@ -0,0 +1,300 @@ +using System; +using System.Globalization; +using System.Threading; + +namespace Halo.Shell; + +internal enum CalendarKind { Gregorian, SolarHijri, SolarHijriAfghan, LunarHijri } + +internal static class Almanac +{ + + internal sealed record Weather(int TempC, int Code, bool Day = true); + + internal static volatile Weather? Latest; + + internal static string? Place { get; private set; } = CityFromTimeZone(); + + internal static void TimeZoneChanged() + { + try + { + TimeZoneInfo.ClearCachedData(); + _zoneId = SafeZoneId(); + Place = CityFromTimeZone(); + _coords = null; + PlaceCountry = null; + FromDevice = false; + Latest = null; + + System.Threading.ThreadPool.QueueUserWorkItem(_ => Refresh()); + } + catch { } + } + + private static long _nextZoneCheck; + private static string? _zoneId = SafeZoneId(); + + private static string? SafeZoneId() + { + try { return TimeZoneInfo.Local.Id; } catch { return null; } + } + + internal static void SyncZone() + { + try + { + if (Environment.TickCount64 < _nextZoneCheck) return; + _nextZoneCheck = Environment.TickCount64 + 60_000; + TimeZoneInfo.ClearCachedData(); + var id = SafeZoneId(); + if (id == _zoneId) return; + TimeZoneChanged(); + } + catch { } + } + + internal static string? CityFromTimeZone() + { + try + { + var id = TimeZoneInfo.Local.Id; + + if (!TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var iana) || string.IsNullOrEmpty(iana)) + iana = id; + return CityFromIana(iana); + } + catch { return null; } + } + + internal static string? CityFromIana(string iana) + { + int slash = iana.LastIndexOf('/'); + var city = (slash >= 0 ? iana[(slash + 1)..] : iana).Replace('_', ' ').Trim(); + + return city.Length == 0 || city.Contains("GMT", StringComparison.OrdinalIgnoreCase) + || city.Equals("UTC", StringComparison.OrdinalIgnoreCase) ? null : city; + } + + private static Timer? _timer; + private static readonly System.Net.Http.HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(8) }; + private static (double lat, double lon)? _coords; + + public static void Poke() => _timer ??= new Timer(_ => Refresh(), null, 20_000, 1_800_000); + + private static void Refresh() + { + try + { + if (Coords() is not { } c) return; + var url = "https://api.open-meteo.com/v1/forecast?current=temperature_2m,weather_code,is_day" + + "&latitude=" + c.lat.ToString("0.####", CultureInfo.InvariantCulture) + + "&longitude=" + c.lon.ToString("0.####", CultureInfo.InvariantCulture); + using var doc = System.Text.Json.JsonDocument.Parse(Http.GetStringAsync(url).Result); + var cur = doc.RootElement.GetProperty("current"); + Latest = new Weather( + (int)Math.Round(cur.GetProperty("temperature_2m").GetDouble()), + cur.GetProperty("weather_code").GetInt32(), + !cur.TryGetProperty("is_day", out var day) || day.GetInt32() != 0); + } + catch { } + } + + private static (double lat, double lon)? Coords() + { + if (_coords is { } cached) return cached; + if (DeviceLocation() is { } live) + { + _coords = live; + FromDevice = true; + if (PlaceCountry is null && Place is { Length: > 0 } named) _ = Geocode(named); + return _coords; + } + if (Place is not { Length: > 0 } city) return null; + _coords = Geocode(city); + return _coords; + } + + internal static volatile bool FromDevice; + + private static (double lat, double lon)? DeviceLocation() + { + try + { + if (!LocationAllowed()) return null; + var geo = new Windows.Devices.Geolocation.Geolocator + { + DesiredAccuracy = Windows.Devices.Geolocation.PositionAccuracy.Default, + + ReportInterval = 0, + }; + var task = geo.GetGeopositionAsync(TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(8)).AsTask(); + if (!task.Wait(TimeSpan.FromSeconds(9))) return null; + var p = task.Result?.Coordinate?.Point?.Position; + return p is { } pos ? (pos.Latitude, pos.Longitude) : null; + } + catch { return null; } + } + + private static bool LocationAllowed() + { + try + { + const string key = @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"; + using var k = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(key); + if (k?.GetValue("Value") as string is { } v) + return string.Equals(v, "Allow", StringComparison.OrdinalIgnoreCase); + using var m = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(key); + return string.Equals(m?.GetValue("Value") as string, "Allow", StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + private static (double lat, double lon)? Geocode(string city) + { + try + { + var url = "https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json&name=" + + Uri.EscapeDataString(city); + using var doc = System.Text.Json.JsonDocument.Parse(Http.GetStringAsync(url).Result); + if (!doc.RootElement.TryGetProperty("results", out var r) || r.GetArrayLength() == 0) return null; + if (r[0].TryGetProperty("country_code", out var cc) && cc.GetString() is { Length: 2 } code) + PlaceCountry = code.ToUpperInvariant(); + return (r[0].GetProperty("latitude").GetDouble(), r[0].GetProperty("longitude").GetDouble()); + } + catch { return null; } + } + + internal static volatile string? PlaceCountry; + + internal static bool MetricFor(string? cc, bool fallback) + => cc is { Length: 2 } c ? c is not ("US" or "LR" or "MM") : fallback; + + internal static CalendarKind CalendarFor(string? cc, CalendarKind fallback) + => cc is { Length: 2 } c + ? c switch + { + "IR" => CalendarKind.SolarHijri, + + "AF" => CalendarKind.SolarHijriAfghan, + "SA" => CalendarKind.LunarHijri, + _ => CalendarKind.Gregorian, + } + : fallback; + + internal static bool Metric => MetricFor(PlaceCountry, RegionMetric); + + internal static CalendarKind Calendar => CalendarFor(PlaceCountry, RegionCalendar); + + private static bool RegionMetric + { + get { try { return RegionInfo.CurrentRegion.IsMetric; } catch { return true; } } + } + + private static CalendarKind RegionCalendar + { + get + { + try { return CalendarFor(RegionInfo.CurrentRegion.TwoLetterISORegionName, CalendarKind.Gregorian); } + catch { return CalendarKind.Gregorian; } + } + } + + private static readonly string[] JalaliMonths = + { + "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar", + "Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand", + }; + + private static readonly string[] AfghanMonths = + { + "Hamal", "Sawr", "Jawza", "Saratan", "Asad", "Sunbula", + "Mizan", "Aqrab", "Qaws", "Jadi", "Dalw", "Hut", + }; + + private static readonly string[] HijriMonths = + { + "Muharram", "Safar", "Rabi I", "Rabi II", "Jumada I", "Jumada II", + "Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qi'dah", "Dhu al-Hijjah", + }; + + internal static string? JalaliDate(DateTime now) => SolarDate(now, JalaliMonths); + + internal static string? AfghanDate(DateTime now) => SolarDate(now, AfghanMonths); + + private static string? SolarDate(DateTime now, string[] months) + { + try + { + var cal = new PersianCalendar(); + return cal.GetDayOfMonth(now) + " " + months[cal.GetMonth(now) - 1]; + } + catch { return null; } + } + + internal static string? HijriDate(DateTime now) + { + try + { + var cal = new UmAlQuraCalendar(); + return cal.GetDayOfMonth(now) + " " + HijriMonths[cal.GetMonth(now) - 1]; + } + catch { return null; } + } + + internal static string? DateIn(CalendarKind kind, DateTime now) => kind switch + { + CalendarKind.SolarHijri => JalaliDate(now), + CalendarKind.SolarHijriAfghan => AfghanDate(now), + CalendarKind.LunarHijri => HijriDate(now), + _ => null, + }; + + internal static (int glyph, int hue) SkyBadge(int code, bool day) => code switch + { + + 0 or 1 => day ? (0xE706, 30) : (0xE708, 232), + 2 => day ? (0xE706, 26) : (0xE708, 226), + 45 or 48 => (0xE753, 196), + 51 or 53 or 55 or 56 or 57 => (0xE753, 208), + 61 or 63 or 65 or 66 or 67 or 80 or 81 or 82 => (0xE753, 220), + 71 or 73 or 75 or 77 or 85 or 86 => (0xEA38, 188), + 95 or 96 or 99 => (0xE753, 280), + _ => (0xE753, 210), + }; + + internal static string Sky(int code) => code switch + { + 0 => "clear", + 1 or 2 => "fair", + 3 => "overcast", + 45 or 48 => "fog", + 51 or 53 or 55 or 56 or 57 => "drizzle", + 61 or 63 or 65 or 66 or 67 => "rain", + 71 or 73 or 75 or 77 => "snow", + 80 or 81 or 82 => "showers", + 85 or 86 => "snow showers", + 95 or 96 or 99 => "storm", + _ => "", + }; + + private static string Temp(int c, bool metric) + => (metric ? c : (int)Math.Round(c * 9 / 5.0 + 32)) + "°"; + + internal static string Label => Place is { Length: > 0 } p ? p : "Clock"; + + internal static string Headline(DateTime now, Weather? w, bool metric) + { + var t = now.ToString("h:mm tt", CultureInfo.InvariantCulture); + return w is null ? t : t + " · " + Temp(w.TempC, metric); + } + + internal static string Detail(DateTime now, CalendarKind kind) + => now.ToString("dddd", CultureInfo.InvariantCulture) + ", " + + (DateIn(kind, now) is { Length: > 0 } d + ? d : now.ToString("d MMM", CultureInfo.InvariantCulture)); + + internal static string Headline(DateTime now) => Headline(now, Latest, Metric); + + internal static string Detail(DateTime now) => Detail(now, Calendar); +} diff --git a/src/Halo.App/Shell/Badges.cs b/src/Halo.App/Shell/Badges.cs new file mode 100644 index 0000000..6b16e7f --- /dev/null +++ b/src/Halo.App/Shell/Badges.cs @@ -0,0 +1,112 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using Halo.Widgets; + +namespace Halo.Shell; + +internal static class Badges +{ + private static readonly FontFamily GlyphFont = new("Segoe Fluent Icons"); + + internal static Bitmap Local(int glyphCp, int hue, float glyphPx = 30f) + { + var b = new Bitmap(64, 64, PixelFormat.Format32bppPArgb); + using var g = Graphics.FromImage(b); + g.SmoothingMode = SmoothingMode.AntiAlias; + var box = new RectangleF(3, 3, 58, 58); + using var tile = Fx.Rounded(box, 17f); + using (var lg = new LinearGradientBrush(box, + Fx.HsvToRgb(hue, 0.62f, 0.96f), Fx.HsvToRgb((hue + 24) % 360, 0.74f, 0.78f), 90f)) + g.FillPath(lg, tile); + + var clipped = g.Save(); + g.SetClip(tile); + using (var sheenPath = new GraphicsPath()) + { + sheenPath.AddEllipse(-14f, -40f, 92f, 74f); + using var sheen = new PathGradientBrush(sheenPath) + { + CenterPoint = new PointF(26f, -10f), + CenterColor = Color.FromArgb(78, 255, 255, 255), + SurroundColors = [Color.FromArgb(0, 255, 255, 255)], + }; + g.FillPath(sheen, sheenPath); + } + g.Restore(clipped); + using (var rim = new Pen(Color.FromArgb(42, 255, 255, 255), 1f)) + g.DrawPath(rim, tile); + + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(((char)glyphCp).ToString(), GlyphFont, (int)FontStyle.Regular, glyphPx, PointF.Empty, sf); + path.Flatten(); + var gb = path.GetBounds(); + if (gb.Width <= 0 || gb.Height <= 0) return b; + using (var m = new Matrix()) + { + m.Translate(MathF.Round(32f - gb.Width / 2f - gb.X), MathF.Round(32f - gb.Height / 2f - gb.Y)); + path.Transform(m); + } + using (var shadow = new Matrix()) + { + shadow.Translate(0f, 1.4f); + using var lowered = (GraphicsPath)path.Clone(); + lowered.Transform(shadow); + using var sb = new SolidBrush(Color.FromArgb(58, 0, 0, 0)); + g.FillPath(sb, lowered); + } + using (var wb = new SolidBrush(Color.FromArgb(248, 255, 255, 255))) + g.FillPath(wb, path); + return b; + } + + internal static Bitmap Language(string code) + { + int hue = ((code.Length > 0 ? code[0] : 'A') * 37 + (code.Length > 1 ? code[1] : 0) * 17) % 360; + var b = new Bitmap(64, 64, PixelFormat.Format32bppPArgb); + using var g = Graphics.FromImage(b); + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + var box = new RectangleF(3, 3, 58, 58); + using (var lg = new LinearGradientBrush(box, + Fx.HsvToRgb(hue, 0.60f, 0.96f), Fx.HsvToRgb((hue + 20) % 360, 0.72f, 0.78f), 90f)) + using (var p = Fx.Rounded(box, 17f)) + g.FillPath(lg, p); + using var f = new Font("Segoe UI Semibold", 25f, GraphicsUnit.Pixel); + using var wb = new SolidBrush(Color.FromArgb(245, 255, 255, 255)); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(code, f, wb, new RectangleF(0, 0, 64, 64), sf); + return b; + } + + internal static Bitmap BatteryLow() => Local(0xE852, 35); + internal static Bitmap BatteryDead() => Local(0xE851, 4); + internal static Bitmap Cpu() => Local(0xE950, 18); + internal static Bitmap Memory() => Local(0xE964, 318); + internal static Bitmap NetSlow() => Local(0xEB63, 40, 34f); + internal static Bitmap NetDown() => Local(0xEB5E, 4, 34f); + + internal static Bitmap ApiDown() => Local(0xE99A, 348, 33f); + internal static Bitmap Limit() => Local(0xE945, 285); + internal static Bitmap LimitLong() => Local(0xE787, 258); + internal static Bitmap Context() => Local(0xEC4A, 55, 34f); + internal static Bitmap Clock() => Local(0xE917, 205); + internal static Bitmap Shot() => Local(0xE722, 200, 28f); + internal static Bitmap Clip() => Local(0xE8C8, 155, 28f); + + internal static Bitmap Hourly() + { + if (Almanac.Latest is not { } wx) return Clock(); + var (glyph, hue) = Almanac.SkyBadge(wx.Code, wx.Day); + return Local(glyph, hue, 32f); + } + + internal static Bitmap[] All() => + [ + BatteryLow(), BatteryDead(), Cpu(), Memory(), NetSlow(), NetDown(), ApiDown(), + Limit(), LimitLong(), Context(), Clock(), Shot(), Clip(), + Local(0xE706, 30, 32f), Local(0xE708, 232, 32f), Local(0xE753, 220, 32f), Local(0xEA38, 188, 32f), + ]; +} diff --git a/src/Halo.App/Shell/GreetingGate.cs b/src/Halo.App/Shell/GreetingGate.cs new file mode 100644 index 0000000..d049eb7 --- /dev/null +++ b/src/Halo.App/Shell/GreetingGate.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; + +namespace Halo.Shell; + +internal enum GreetingKind +{ + None, + Install, + Login, +} + +internal static class GreetingGate +{ + internal static GreetingKind Decide(string? marker, string version) + => string.IsNullOrWhiteSpace(marker) || marker.Trim() != version + ? GreetingKind.Install + : GreetingKind.Login; + + internal static string Version => + typeof(GreetingGate).Assembly.GetName().Version?.ToString() ?? "0"; + + internal static GreetingKind Read(string path) + { + try { return Decide(File.Exists(path) ? File.ReadAllText(path) : null, Version); } + catch { return GreetingKind.Login; } + } + + internal static void Mark(string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, Version); + } + catch { } + } +} diff --git a/src/Halo.App/Shell/GreetingPlan.cs b/src/Halo.App/Shell/GreetingPlan.cs new file mode 100644 index 0000000..fe9a568 --- /dev/null +++ b/src/Halo.App/Shell/GreetingPlan.cs @@ -0,0 +1,76 @@ +using System; + +namespace Halo.Shell; + +internal readonly record struct GreetingFrame( + float PillW, + float PillH, + float Radius, + float Written, + float HelloAlpha, + float LineWritten, + float LineAlpha, + int LineIndex); + +internal static class GreetingPlan +{ + internal const int CollapsedW = 220, CollapsedH = 40, CollapsedR = 20; + + internal const int OpenW = 460, OpenH = 150, OpenR = 30; + + internal const float InstallSeconds = 10.2f; + internal const float LoginSeconds = 2.6f; + + internal static GreetingFrame Install(float t) + { + t = Math.Clamp(t, 0f, 1f); + + float open = Span(t, 0f, 0.07f), shut = Span(t, 0.96f, 1f); + float size = EaseOutBack(open) * (1f - EaseInOut(shut)); + + float written = Span(t, 0.04f, 0.25f); + + float helloOut = EaseInOut(Span(t, 0.29f, 0.38f)); + float write1 = Span(t, 0.35f, 0.56f), out1 = EaseInOut(Span(t, 0.62f, 0.71f)); + float write2 = Span(t, 0.67f, 0.88f), out2 = EaseInOut(Span(t, 0.92f, 1f)); + + bool second = write2 > 0f; + return new GreetingFrame( + Lerp(CollapsedW, OpenW, size), + Lerp(CollapsedH, OpenH, size), + Lerp(CollapsedR, OpenR, size), + written, + (1f - helloOut) * Math.Min(1f, open * 3f), + second ? write2 : write1, + second ? 1f - out2 : (write1 <= 0f ? 0f : 1f - out1), + second ? 1 : 0); + } + + internal static GreetingFrame Login(float t) + { + t = Math.Clamp(t, 0f, 1f); + float written = Span(t, 0.04f, 0.58f); + float fade = EaseInOut(Span(t, 0.78f, 1f)); + return new GreetingFrame(CollapsedW, CollapsedH, CollapsedR, written, 1f - fade, 0f, 0f, 0); + } + + internal static float Span(float t, float a, float b) + => b <= a ? (t >= b ? 1f : 0f) : Math.Clamp((t - a) / (b - a), 0f, 1f); + + private static float Lerp(float a, float b, float t) => a + (b - a) * t; + + private static float EaseOutSine(float t) => MathF.Sin(Math.Clamp(t, 0f, 1f) * MathF.PI / 2f); + + private static float EaseInOut(float t) + { + t = Math.Clamp(t, 0f, 1f); + return t < 0.5f ? 2f * t * t : 1f - MathF.Pow(-2f * t + 2f, 2f) / 2f; + } + + private static float EaseOutBack(float t) + { + t = Math.Clamp(t, 0f, 1f); + const float c1 = 1.70158f, c3 = c1 + 1f; + return 1f + c3 * MathF.Pow(t - 1f, 3f) + c1 * MathF.Pow(t - 1f, 2f); + } +} diff --git a/src/Halo.App/Shell/LayeredNotch.cs b/src/Halo.App/Shell/LayeredNotch.cs new file mode 100644 index 0000000..488afc7 --- /dev/null +++ b/src/Halo.App/Shell/LayeredNotch.cs @@ -0,0 +1,1112 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Runtime.InteropServices; +using Halo.Interop; + +namespace Halo.Shell; + +internal struct MenuFrame +{ + public bool Show; + public float Appear; + public string[] RowIcons; + public Bitmap?[] RowImages; + public float[] RowImageOffsets; + public int[] RowCounts; + public Bitmap?[][] SessImages; + public string[][] SessIcons; + public Color?[] RowRings; + public float[] RowProgress; + public Color?[][] SessRings; + public float Open; + public int OpenRow; + public float RowOpen; + public bool Dropping; + public bool Outward; + public string DropIcon; + public Bitmap? DropImage; + public float Drop; + public float FromX, FromY, ToX, ToY; + public int CarryRow; + public float CarryDY; + public float[] RowShift; +} + +internal sealed class LayeredNotch +{ + private const int CaptureW = 560, CaptureBaseH = 220; + + internal static int CaptureH { get; private set; } = CaptureBaseH; + + internal static void WantCaptureHeight(int logicalHeight) + => CaptureH = Math.Max(CaptureBaseH, Math.Min(720, logicalHeight + 8)); + + public const int CircleD = 40, CircleGap = 4, CircleY = 0; + + private const int PrivacyGap = 10; + public static int PrivacyPad => Widgets.Privacy.Active ? PrivacyGap : 0; + + private Win32.WndProc _wndProc = null!; + private int _workLeft, _workTop, _workWidth; + + public float Scale = 1f; + public float OffsetX; + public float HandleAlpha; + private static readonly string ScalePath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "scale"); + + public void LoadScale() + { + try + { + if (float.TryParse(System.IO.File.ReadAllText(ScalePath), + System.Globalization.CultureInfo.InvariantCulture, out var s)) + Scale = Math.Clamp(s, 0.7f, 1.6f); + } + catch { } + } + + public void SaveScale() + { + try + { + System.IO.File.WriteAllText(ScalePath, + Scale.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + catch { } + } + private Bitmap? _bg; + private readonly object _bgLock = new(); + private volatile bool _capturing; + private int _captureVersion; + + public int CaptureVersion => _captureVersion; + + public IntPtr Hwnd { get; private set; } + public int WorkLeft => _workLeft; + public int WorkTop => _workTop; + public int WorkWidth => _workWidth; + + public event Action? ClipboardImage; + private uint _lastClipSeq; + private long _lastClipTick; + + private static readonly string[] SnipHosts = + { "screenclippinghost", "snippingtool", "screensketch", "shellexperiencehost", + "greenshot", "sharex", "lightshot", "flameshot", "snagit32", "snagiteditor", "picpick" }; + + public void Show() + { + var hInstance = Win32.GetModuleHandle(null); + _wndProc = WndProc; + + var wc = new Win32.WNDCLASSEX + { + cbSize = Marshal.SizeOf(), + lpfnWndProc = _wndProc, + hInstance = hInstance, + hCursor = Win32.LoadCursor(IntPtr.Zero, Win32.IDC_ARROW), + lpszClassName = "HaloNotchWindow", + }; + if (Win32.RegisterClassEx(ref wc) == 0) + throw new InvalidOperationException($"RegisterClassEx failed: {Marshal.GetLastWin32Error()}"); + + var work = default(Win32.RECT); + Win32.SystemParametersInfo(Win32.SPI_GETWORKAREA, 0, ref work, 0); + _workLeft = work.left; + _workTop = work.top; + _workWidth = work.right - work.left; + LoadScale(); + + int exStyle = Win32.WS_EX_LAYERED | Win32.WS_EX_TOOLWINDOW | Win32.WS_EX_TOPMOST | Win32.WS_EX_NOACTIVATE; + Hwnd = Win32.CreateWindowEx(exStyle, "HaloNotchWindow", "Halo", Win32.WS_POPUP, + _workLeft, _workTop, 10, 10, IntPtr.Zero, IntPtr.Zero, hInstance, IntPtr.Zero); + if (Hwnd == IntPtr.Zero) + throw new InvalidOperationException($"CreateWindowEx failed: {Marshal.GetLastWin32Error()}"); + + Win32.ShowWindow(Hwnd, Win32.SW_SHOWNOACTIVATE); + + SetCapturable(false); + Win32.AddClipboardFormatListener(Hwnd); + + _dropTarget = new Halo.Interop.FileDropTarget(); + Win32.RegisterDragDrop(Hwnd, _dropTarget); + } + + private Halo.Interop.FileDropTarget? _dropTarget; + + public void SetCapturable(bool on) + { + if (Environment.GetEnvironmentVariable("HALO_CAPTURABLE") == "1") on = true; + _capturable = on; + Win32.SetWindowDisplayAffinity(Hwnd, on ? 0u : Win32.WDA_EXCLUDEFROMCAPTURE); + } + + private volatile bool _capturable; + + public void SetVisible(bool visible) + => Win32.ShowWindow(Hwnd, visible ? Win32.SW_SHOWNOACTIVATE : Win32.SW_HIDE); + + public void AssertTopmost() + => Win32.SetWindowPos(Hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0, + Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_NOACTIVATE); + + public bool IsFullscreen(IntPtr fg) + { + if (fg == IntPtr.Zero || fg == Hwnd || IsDesktopWindow(fg)) return false; + if (!Win32.GetWindowRect(fg, out var r)) return false; + int cx = Win32.GetSystemMetrics(Win32.SM_CXSCREEN); + int cy = Win32.GetSystemMetrics(Win32.SM_CYSCREEN); + return r.left <= 0 && r.top <= 0 && r.right >= cx && r.bottom >= cy; + } + + public bool ProbeBehind(out IntPtr behindRoot) + { + int cx = _workLeft + _workWidth / 2, cy = _workTop + 6; + Win32.ShowWindow(Hwnd, Win32.SW_HIDE); + System.Threading.Thread.Sleep(12); + + var behind = Win32.WindowFromPoint(new Win32.POINT { X = cx, Y = cy }); + var root = behind == IntPtr.Zero ? IntPtr.Zero : Win32.GetAncestor(behind, Win32.GA_ROOT); + bool isDesktop = IsDesktopWindow(behind) || IsDesktopWindow(root); + + Win32.ShowWindow(Hwnd, Win32.SW_SHOWNOACTIVATE); + + AssertTopmost(); + behindRoot = isDesktop ? IntPtr.Zero : root; + return isDesktop; + } + + public void CaptureFrom(IntPtr behind) + { + if (behind == IntPtr.Zero || _capturing) return; + _capturing = true; + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { DoCapture(behind); } catch { } finally { _capturing = false; } + }); + } + + private void DoCapture(IntPtr behind) + { + if (!Win32.GetWindowRect(behind, out var wr)) return; + + int nx = _workLeft + (_workWidth - CaptureW) / 2 + (int)OffsetX, ny = _workTop; + int sx = nx - wr.left, sy = ny - wr.top; + long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); + string how; + + Bitmap? raw = _capturable ? null : GrabScreen(nx, ny); + how = raw != null ? "screen" : ""; + + if (raw == null && _capturable) + { + var direct = CaptureViaPrintWindow(behind, wr, sx, sy); + if (direct != null) { raw = direct; how = "printwindow"; } + } + + if (raw == null) + { + raw = new Bitmap(CaptureW, CaptureH, PixelFormat.Format24bppRgb); + IntPtr src = Win32.GetWindowDC(behind); + using (var g = Graphics.FromImage(raw)) + { + g.Clear(Color.FromArgb(24, 24, 24)); + IntPtr dhdc = g.GetHdc(); + Win32.BitBlt(dhdc, 0, 0, CaptureW, CaptureH, src, sx, sy, Win32.SRCCOPY); + g.ReleaseHdc(dhdc); + } + Win32.ReleaseDC(behind, src); + how = "window"; + + if (IsMostlyBlack(raw)) + { + var pw = CaptureViaPrintWindow(behind, wr, sx, sy); + if (pw != null) { raw.Dispose(); raw = pw; how = "printwindow"; } + } + } + + var blurred = BlurPyramid(raw); + + if (GlassDump) + { + long nowMs = Environment.TickCount64; + if (nowMs - _lastDump > 2000) + { + _lastDump = nowMs; + try + { + string dir = System.IO.Path.GetTempPath(); + raw.Save(System.IO.Path.Combine(dir, "halo-glass-raw.png"), ImageFormat.Png); + blurred.Save(System.IO.Path.Combine(dir, "halo-glass-blur.png"), ImageFormat.Png); + } + catch { } + } + } + + raw.Dispose(); + + ulong hash = PlateHash(blurred); + if (hash == _bgHash && _bg != null) + { + if (_staleStreak < 1000) _staleStreak++; + blurred.Dispose(); + GlassTrace(how + " same", (System.Diagnostics.Stopwatch.GetTimestamp() - t0) * 1000.0 + / System.Diagnostics.Stopwatch.Frequency); + return; + } + _bgHash = hash; + _staleStreak = 0; + lock (_bgLock) { var old = _bg; _bg = blurred; old?.Dispose(); } + System.Threading.Interlocked.Increment(ref _captureVersion); + GlassTrace(how, (System.Diagnostics.Stopwatch.GetTimestamp() - t0) * 1000.0 / System.Diagnostics.Stopwatch.Frequency); + } + + private ulong _bgHash; + private int _staleStreak; + + internal int StaleStreak => _staleStreak; + + private static ulong PlateHash(Bitmap b) + { + var data = b.LockBits(new Rectangle(0, 0, b.Width, b.Height), ImageLockMode.ReadOnly, + PixelFormat.Format32bppPArgb); + try + { + ulong h = 14695981039346656037UL; + int stepX = Math.Max(1, b.Width / 48), stepY = Math.Max(1, b.Height / 24); + for (int y = 0; y < b.Height; y += stepY) + for (int x = 0; x < b.Width; x += stepX) + { + int px = System.Runtime.InteropServices.Marshal.ReadInt32(data.Scan0, y * data.Stride + x * 4); + h = (h ^ (uint)px) * 1099511628211UL; + } + return h; + } + finally { b.UnlockBits(data); } + } + + private static Bitmap? GrabScreen(int x, int y) + { + IntPtr screen = IntPtr.Zero; + try + { + screen = Win32.GetDC(IntPtr.Zero); + if (screen == IntPtr.Zero) return null; + var bmp = new Bitmap(CaptureW, CaptureH, PixelFormat.Format24bppRgb); + using (var g = Graphics.FromImage(bmp)) + { + g.Clear(Color.FromArgb(24, 24, 24)); + IntPtr dhdc = g.GetHdc(); + bool ok = Win32.BitBlt(dhdc, 0, 0, CaptureW, CaptureH, screen, x, y, Win32.SRCCOPY); + g.ReleaseHdc(dhdc); + if (!ok) { bmp.Dispose(); return null; } + } + return bmp; + } + catch { return null; } + finally { if (screen != IntPtr.Zero) Win32.ReleaseDC(IntPtr.Zero, screen); } + } + + private static readonly bool GlassDebug = + Environment.GetEnvironmentVariable("HALO_GLASS_DEBUG") == "1"; + private static int _traceCount; + + private static readonly bool GlassDump = + Environment.GetEnvironmentVariable("HALO_DUMP_GLASS") == "1"; + private static long _lastDump; + + private static void GlassTrace(string how, double ms) + { + if (!GlassDebug) return; + try + { + if (++_traceCount > 600) return; + string path = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "glass-debug.txt"); + System.IO.File.AppendAllText(path, $"{DateTime.Now:HH:mm:ss.fff} {how} {ms:0.0}ms\n"); + } + catch { } + } + + private static bool IsMostlyBlack(Bitmap bmp) + { + int dark = 0, total = 0; + for (int y = 4; y < bmp.Height; y += 16) + for (int x = 4; x < bmp.Width; x += 16) + { + var p = bmp.GetPixel(x, y); + if (p.R < 12 && p.G < 12 && p.B < 12) dark++; + total++; + } + return total > 0 && dark >= total * 0.97f; + } + + private Bitmap? CaptureViaPrintWindow(IntPtr behind, Win32.RECT wr, int sx, int sy) + { + try + { + int ww = wr.right - wr.left, wh = wr.bottom - wr.top; + if (ww <= 0 || wh <= 0 || ww > 10000 || wh > 10000) return null; + using var full = new Bitmap(ww, wh, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(full)) + { + IntPtr hdc = g.GetHdc(); + bool ok = Win32.PrintWindow(behind, hdc, Win32.PW_RENDERFULLCONTENT); + g.ReleaseHdc(hdc); + if (!ok) return null; + } + var region = new Bitmap(CaptureW, CaptureH, PixelFormat.Format24bppRgb); + using (var g = Graphics.FromImage(region)) + { + g.Clear(Color.FromArgb(24, 24, 24)); + g.DrawImage(full, new Rectangle(0, 0, CaptureW, CaptureH), + new Rectangle(sx, sy, CaptureW, CaptureH), GraphicsUnit.Pixel); + } + return region; + } + catch { return null; } + } + + private static bool IsDesktopWindow(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) return true; + var buf = new char[80]; + int n = Win32.GetClassName(hwnd, buf, buf.Length); + var cls = new string(buf, 0, n); + return cls is "Progman" or "WorkerW" or "SysListView32" or "Shell_TrayWnd"; + } + + public void Render(int w, int h, int radius, int tintAlpha, float contentFade, float collapsedFade, bool glass, + MenuFrame menu, Action drawContent, Action drawCollapsed, + float glassFade = 1f, float clarity = 0f) + { + int menuX = w + CircleGap + PrivacyPad; + + int maxFan = 0; + if (menu.Show) + foreach (var k in menu.RowCounts) maxFan = Math.Max(maxFan, k); + int totalW = menu.Show ? menuX + CircleD * (1 + maxFan) : w; + int totalH = Math.Max(h, menu.Show ? Math.Max(1, menu.RowIcons.Length) * CircleD : 0); + + bool privacy = Widgets.Privacy.Active; + if (privacy) + { + totalW = Math.Max(totalW, w + CircleGap + PrivacyGap); + int nDots = (Widgets.Privacy.Mic ? 1 : 0) + (Widgets.Privacy.Cam ? 1 : 0); + totalH = Math.Max(totalH, (int)Math.Ceiling(DotTop + (nDots - 1) * DotStep + DotR + 2f)); + } + + float S = Scale; + int pw = (int)MathF.Ceiling(totalW * S), ph = (int)MathF.Ceiling(totalH * S); + + var bmi = new Win32.BITMAPINFOHEADER + { + biSize = Marshal.SizeOf(), + biWidth = pw, + biHeight = -ph, + biPlanes = 1, + biBitCount = 32, + biCompression = 0, + }; + IntPtr screenDc = Win32.GetDC(IntPtr.Zero); + IntPtr dib = Win32.CreateDIBSection(screenDc, ref bmi, 0, out var bits, IntPtr.Zero, 0); + IntPtr memDc = Win32.CreateCompatibleDC(screenDc); + IntPtr oldObj = Win32.SelectObject(memDc, dib); + + using (var bmp = new Bitmap(pw, ph, pw * 4, PixelFormat.Format32bppPArgb, bits)) + using (var g = Graphics.FromImage(bmp)) + { + g.Clear(Color.Transparent); + g.ScaleTransform(S, S); + DrawShape(g, w, h, radius, tintAlpha, glass, glassFade, clarity); + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + if (collapsedFade > 0.01f) drawCollapsed(g, w, h, collapsedFade); + drawContent(g, w, h, contentFade); + if (HandleAlpha > 0.01f && contentFade > 0.5f) + { + + using var hp = new Pen(Color.FromArgb((int)(160 * HandleAlpha * contentFade), 255, 255, 255), 3f) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + int m = 3; + g.DrawArc(hp, w - 2 * radius + m, h - 2 * radius + m, 2 * (radius - m), 2 * (radius - m), 25, 40); + } + float ca = 1f - contentFade; + if (menu.Show && ca > 0.01f && !menu.Dropping) DrawMenu(g, menuX, w, tintAlpha, glass, menu, ca); + if (menu.Dropping) DrawDrop(g, menu, tintAlpha, w, h); + if (privacy) DrawPrivacyDots(g, w); + } + + var size = new Win32.SIZE { cx = pw, cy = ph }; + var src = new Win32.POINT { X = 0, Y = 0 }; + var dst = new Win32.POINT { X = _workLeft + (_workWidth - (int)(w * S)) / 2 + (int)OffsetX, Y = _workTop }; + var blend = new Win32.BLENDFUNCTION + { + BlendOp = Win32.AC_SRC_OVER, + BlendFlags = 0, + SourceConstantAlpha = 255, + AlphaFormat = Win32.AC_SRC_ALPHA, + }; + Win32.UpdateLayeredWindow(Hwnd, screenDc, ref dst, ref size, memDc, ref src, 0, ref blend, Win32.ULW_ALPHA); + + Win32.SelectObject(memDc, oldObj); + Win32.DeleteObject(dib); + Win32.DeleteDC(memDc); + Win32.ReleaseDC(IntPtr.Zero, screenDc); + } + + private const float DotR = 3.3f, DotRing = 0.9f, DotStep = 8.5f, DotTop = 9f; + private static readonly Color MicColor = Color.FromArgb(255, 159, 10); + private static readonly Color CamColor = Color.FromArgb(48, 209, 88); + private static void DrawPrivacyDots(Graphics g, int pillW) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + float cx = pillW + (CircleGap + PrivacyGap) / 2f; + float y = DotTop; + if (Widgets.Privacy.Mic) { Dot(g, cx, y, MicColor); y += DotStep; } + if (Widgets.Privacy.Cam) { Dot(g, cx, y, CamColor); } + } + + private static void Dot(Graphics g, float cx, float cy, Color c) + { + using (var kb = new SolidBrush(Color.FromArgb(230, 0, 0, 0))) + g.FillEllipse(kb, cx - DotR, cy - DotR, DotR * 2, DotR * 2); + float ri = DotR - DotRing; + using var cb = new SolidBrush(c); + g.FillEllipse(cb, cx - ri, cy - ri, ri * 2, ri * 2); + } + + internal void DrawShape(Graphics g, int w, int h, int radius, int tintAlpha, bool glass, + float glassFade = 1f, float clarity = 0f) + { + lock (_bgLock) ShapeInto(g, w, h, radius, tintAlpha, glass ? _bg : null, glassFade, clarity); + } + + private static readonly object _scratchLock = new(); + private static Bitmap? _scratchA, _scratchB; + + private static Bitmap Scratch(ref Bitmap? slot, int w, int h) + { + if (slot is { } b && b.Width == w && b.Height == h) return b; + slot?.Dispose(); + slot = new Bitmap(w, h, PixelFormat.Format32bppPArgb); + return slot; + } + + private static Bitmap ScratchA(int w, int h) { lock (_scratchLock) return Scratch(ref _scratchA, w, h); } + private static Bitmap ScratchB(int w, int h) { lock (_scratchLock) return Scratch(ref _scratchB, w, h); } + + private const float FrostDesat = 0.40f, FrostContrast = 0.34f, FrostFloor = 0.05f; + + private static ColorMatrix Frost(float alpha, float clarity) + { + const float lr = 0.2126f, lg = 0.7152f, lb = 0.0722f; + float k = Math.Clamp(clarity, 0f, 1f); + float d = FrostDesat * (1f - k), c = FrostContrast + (1f - FrostContrast) * k; + return new ColorMatrix(new[] + { + new[] { ((1 - d) + lr * d) * c, lr * d * c, lr * d * c, 0f, 0f }, + new[] { lg * d * c, ((1 - d) + lg * d) * c, lg * d * c, 0f, 0f }, + new[] { lb * d * c, lb * d * c, ((1 - d) + lb * d) * c, 0f, 0f }, + new[] { 0f, 0f, 0f, alpha, 0f }, + new[] { FrostFloor * (1 - k), FrostFloor * (1 - k), FrostFloor * (1 - k), 0f, 1f }, + }); + } + + internal static void ShapeInto(Graphics g, int w, int h, int radius, int tintAlpha, + Bitmap? backdrop, float glassFade, float clarity = 0f) + { + const int ss = 2; + + var content = ScratchA(w * ss, h * ss); + using (var cg = Graphics.FromImage(content)) + { + cg.CompositingMode = CompositingMode.SourceCopy; + cg.Clear(Color.Transparent); + cg.CompositingMode = CompositingMode.SourceOver; + if (backdrop != null && glassFade > 0.004f) + { + int sx = (CaptureW - w) / 2; + + int srcW = Math.Min(w, backdrop.Width - Math.Max(0, sx)); + int srcH = Math.Min(h, backdrop.Height); + cg.InterpolationMode = InterpolationMode.HighQualityBilinear; + cg.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetColorMatrix(Frost(Math.Clamp(glassFade, 0f, 1f), clarity)); + if (srcW > 0 && srcH > 0) + cg.DrawImage(backdrop, new Rectangle(0, 0, w * ss, h * ss), + Math.Max(0, sx), 0, srcW, srcH, GraphicsUnit.Pixel, ia); + } + using var tint = new SolidBrush(Color.FromArgb(tintAlpha, 8, 8, 8)); + cg.FillRectangle(tint, 0, 0, w * ss, h * ss); + + if (Sheen > 0.004f) + { + using var lg = new LinearGradientBrush(new Rectangle(0, -1, w * ss, h * ss + 2), + Color.FromArgb((int)(255 * Sheen), 255, 255, 255), Color.FromArgb(0, 255, 255, 255), + LinearGradientMode.Vertical) + { Blend = new Blend { Factors = new[] { 0f, 0.55f, 1f }, Positions = new[] { 0f, 0.30f, 1f } } }; + cg.FillRectangle(lg, 0, 0, w * ss, h * ss); + } + + if (Grain > 0.004f) + { + using var noise = new TextureBrush(GrainTile(), WrapMode.Tile); + cg.FillRectangle(noise, 0, 0, w * ss, h * ss); + } + } + + var big = ScratchB(w * ss, h * ss); + using (var bg = Graphics.FromImage(big)) + { + bg.SmoothingMode = SmoothingMode.AntiAlias; + bg.PixelOffsetMode = PixelOffsetMode.HighQuality; + bg.CompositingMode = CompositingMode.SourceCopy; + bg.Clear(Color.Transparent); + bg.CompositingMode = CompositingMode.SourceOver; + using var path = PillPath(w * ss, h * ss, radius * ss); + using var mask = new TextureBrush(content) { WrapMode = WrapMode.Clamp }; + bg.FillPath(mask, path); + + if (RimLight > 0.004f) + { + using var rim = new Pen(Color.FromArgb((int)(255 * RimLight), 255, 255, 255), ss) + { Alignment = PenAlignment.Inset }; + bg.DrawPath(rim, path); + } + } + + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.DrawImage(big, new Rectangle(0, 0, w, h), new Rectangle(0, 0, w * ss, h * ss), GraphicsUnit.Pixel); + } + + private void DrawMenu(Graphics g, int x, int pillW, int tintAlpha, bool glass, MenuFrame menu, float alpha) + { + alpha *= menu.Appear; + if (alpha <= 0.01f) return; + int rows = menu.RowIcons.Length; + float openV = Math.Max(0f, menu.Open); + float hf = CircleD + (rows - 1) * CircleD * openV; + int or_ = menu.OpenRow; + float rowEase = Math.Max(0f, menu.RowOpen); + float extf = or_ >= 0 && or_ < rows ? menu.RowCounts[or_] * CircleD * rowEase : 0f; + if (or_ > 0 && CircleD + or_ * CircleD > hf + 0.5f) extf = 0f; + int mw = (int)Math.Ceiling(CircleD + extf); + int mh = (int)Math.Ceiling(hf); + const int ss = 2; + int D = CircleD * ss; + + using var c = new Bitmap(mw * ss, mh * ss, PixelFormat.Format32bppPArgb); + using (var cg = Graphics.FromImage(c)) + { + cg.SmoothingMode = SmoothingMode.AntiAlias; + cg.PixelOffsetMode = PixelOffsetMode.HighQuality; + cg.InterpolationMode = InterpolationMode.HighQualityBicubic; + cg.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + cg.Clear(Color.Transparent); + + using var path = new GraphicsPath(FillMode.Winding); + using (var v = PillPath(D, mh * ss, D / 2)) + path.AddPath(v, false); + if (extf > 0.5f) + using (var hp = PillPath((int)((CircleD + extf) * ss), D, D / 2)) + { + using var m = new Matrix(1, 0, 0, 1, 0, or_ * D); + hp.Transform(m); + path.AddPath(hp, false); + } + + int srcX = (CaptureW - pillW) / 2 + x; + lock (_bgLock) + { + if (glass && _bg != null && srcX >= 0 && srcX + mw <= _bg.Width && CircleY + mh <= _bg.Height) + { + var clip = cg.Clip; + cg.SetClip(path); + cg.DrawImage(_bg, new Rectangle(0, 0, mw * ss, mh * ss), + new Rectangle(srcX, CircleY, mw, mh), GraphicsUnit.Pixel); + cg.Clip = clip; + } + } + using (var b = new SolidBrush(Color.FromArgb(tintAlpha, 8, 8, 8))) + cg.FillPath(b, path); + + void Cell(string icon, Bitmap? img, float cx, float cy, float ia, Color? ring, + float progress = -1f, float imageOffsetX = 0f) + { + if (ia <= 0.01f) return; + if (img != null) + { + + var accent = Widgets.Fx.AccentOf(img); + if (accent != Widgets.Fx.White) + { + using var wash = new System.Drawing.Drawing2D.GraphicsPath(); + wash.AddEllipse(cx - D * 0.1f, cy - D * 0.1f, D * 1.2f, D * 1.2f); + using var pgb = new System.Drawing.Drawing2D.PathGradientBrush(wash) + { + CenterColor = Color.FromArgb((int)(34 * ia), accent), + SurroundColors = new[] { Color.FromArgb(0, accent) }, + }; + cg.FillPath(pgb, wash); + } + DrawCircleImage(cg, img, cx + imageOffsetX * ss, cy, D, ia); + } + else + DrawGlyphCentered(cg, icon, cx, cy, D, D * 0.45f, (int)(235 * ia)); + if (ring is { } rc) + { + float inset = D * 0.19f - 2.5f * ss, dd = D - inset * 2; + var rr = new RectangleF(cx + inset, cy + inset, dd, dd); + if (progress >= 0f) + { + + cg.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using var track = new Pen(Color.FromArgb((int)(55 * ia), rc), 1.9f * ss); + cg.DrawEllipse(track, rr); + if (progress > 0.001f) + using (var arc = new Pen(Color.FromArgb((int)(230 * ia), rc), 2.2f * ss) + { StartCap = System.Drawing.Drawing2D.LineCap.Round, EndCap = System.Drawing.Drawing2D.LineCap.Round }) + cg.DrawArc(arc, rr, -90f, 360f * Math.Clamp(progress, 0f, 1f)); + } + else + { + using var pen = new Pen(Color.FromArgb((int)(140 * ia), rc), 1.9f * ss); + cg.DrawEllipse(pen, rr); + } + } + } + + int carry = menu.CarryRow; + bool carrying = carry >= 0 && carry < rows && menu.Drop <= 0f; + for (int i = 0; i < rows; i++) + { + if (carrying && i == carry) continue; + + float slide = menu.RowShift is { } sh && i < sh.Length ? sh[i] : 0f; + Cell(menu.RowIcons[i], menu.RowImages[i], 0, i * D + slide * ss, + Math.Clamp((hf - i * CircleD) / CircleD, 0f, 1f), menu.RowRings[i], menu.RowProgress[i], + menu.RowImageOffsets[i]); + } + if (carrying) + Cell(menu.RowIcons[carry], menu.RowImages[carry], 0, (carry * CircleD + menu.CarryDY) * ss, + 1f, menu.RowRings[carry], menu.RowProgress[carry], menu.RowImageOffsets[carry]); + if (extf > 0.5f) + for (int j = 0; j < menu.RowCounts[or_]; j++) + Cell(menu.SessIcons[or_][j], menu.SessImages[or_][j], (j + 1) * D, or_ * D, + Math.Clamp((extf - j * CircleD) / CircleD, 0f, 1f), menu.SessRings[or_][j]); + } + + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + var dst = new Rectangle(x, CircleY, mw, mh); + if (alpha >= 0.999f) { g.DrawImage(c, dst, 0, 0, c.Width, c.Height, GraphicsUnit.Pixel); return; } + + var stt = g.Save(); + float ax = x, ay = CircleY + CircleD / 2f; + g.TranslateTransform(ax, ay); + g.ScaleTransform(alpha, alpha); + g.TranslateTransform(-ax, -ay); + using (var attr = new ImageAttributes()) + { + attr.SetColorMatrix(new ColorMatrix { Matrix33 = alpha }); + g.DrawImage(c, dst, 0, 0, c.Width, c.Height, GraphicsUnit.Pixel, attr); + } + g.Restore(stt); + } + + private void DrawDrop(Graphics g, MenuFrame menu, int tintAlpha, int w, int h) + { + + float p = menu.Drop - 1f; + const float k1 = 1.9f, k3 = k1 + 1f; + float e = 1f + k3 * p * p * p + k1 * p * p; + float bx = menu.FromX + (menu.ToX - menu.FromX) * e; + float by = menu.FromY + (menu.ToY - menu.FromY) * e; + + float r2 = CircleD / 2f * (menu.Outward ? 0.8f + 0.2f * e : 1f - 0.2f * e); + var blob = new PointF(bx, by); + var c1 = new PointF(w - h / 2f, h / 2f); + float r1 = h / 2f; + + var fill = Color.FromArgb(Math.Min(tintAlpha + 50, 255), 8, 8, 8); + using (var b = new SolidBrush(fill)) + { + Metaball(g, b, c1, r1, blob, r2); + g.FillEllipse(b, blob.X - r2, blob.Y - r2, r2 * 2, r2 * 2); + } + + float a = menu.Outward + ? Math.Clamp(menu.Drop / 0.25f, 0f, 1f) + : menu.Drop < 0.8f ? 1f : 1f - (menu.Drop - 0.8f) / 0.2f; + if (menu.DropImage != null) + { + DrawCircleImage(g, menu.DropImage, blob.X - r2, blob.Y - r2, r2 * 2, a); + return; + } + + DrawGlyphCentered(g, menu.DropIcon, blob.X - r2, blob.Y - r2, r2 * 2, r2 * 1.8f, (int)(235 * a)); + } + + private static void Metaball(Graphics g, Brush brush, PointF c1, float r1, PointF c2, float r2) + { + float dx = c2.X - c1.X, dy = c2.Y - c1.Y; + float d = MathF.Sqrt(dx * dx + dy * dy); + if (d <= 0.001f || d >= r1 + r2 || d <= MathF.Abs(r1 - r2)) return; + + const float handle = 2.4f, v = 0.5f; + float u1 = MathF.Acos((r1 * r1 + d * d - r2 * r2) / (2 * r1 * d)); + float u2 = MathF.Acos((r2 * r2 + d * d - r1 * r1) / (2 * r2 * d)); + float ab = MathF.Atan2(dy, dx); + float maxSpread = MathF.Acos((r1 - r2) / d); + + float a1 = ab + u1 + (maxSpread - u1) * v; + float a2 = ab - u1 - (maxSpread - u1) * v; + float a3 = ab + MathF.PI - u2 - (MathF.PI - u2 - maxSpread) * v; + float a4 = ab - MathF.PI + u2 + (MathF.PI - u2 - maxSpread) * v; + + var p1 = Pt(c1, r1, a1); var p2 = Pt(c1, r1, a2); + var p3 = Pt(c2, r2, a3); var p4 = Pt(c2, r2, a4); + + float total = r1 + r2; + float d2 = Math.Min(v * handle, Dist(p1, p3) / total) * Math.Min(1f, d * 2f / total); + float h1 = r1 * d2, h2 = r2 * d2; + + using var path = new GraphicsPath(); + path.AddBezier(p1, Pt(p1, h1, a1 - MathF.PI / 2), Pt(p3, h2, a3 + MathF.PI / 2), p3); + path.AddLine(p3, p4); + path.AddBezier(p4, Pt(p4, h2, a4 - MathF.PI / 2), Pt(p2, h1, a2 + MathF.PI / 2), p2); + path.CloseFigure(); + g.FillPath(brush, path); + } + + private static PointF Pt(PointF c, float r, float a) => new(c.X + r * MathF.Cos(a), c.Y + r * MathF.Sin(a)); + private static float Dist(PointF a, PointF b) { float dx = a.X - b.X, dy = a.Y - b.Y; return MathF.Sqrt(dx * dx + dy * dy); } + + private static readonly FontFamily _cellGlyphFont = new("Segoe MDL2 Assets"); + private static void DrawGlyphCentered(Graphics g, string glyph, float x, float y, float box, float px, int alpha) + { + if (string.IsNullOrEmpty(glyph)) return; + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(glyph, _cellGlyphFont, (int)FontStyle.Regular, px, PointF.Empty, sf); + path.Flatten(); + var b = path.GetBounds(); + if (b.Width <= 0 || b.Height <= 0) return; + using var m = new Matrix(); + m.Translate(MathF.Round(x + (box - b.Width) / 2f - b.X), MathF.Round(y + (box - b.Height) / 2f - b.Y)); + path.Transform(m); + using var br = new SolidBrush(Color.FromArgb(alpha, 255, 255, 255)); + var old = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.FillPath(br, path); + g.SmoothingMode = old; + } + + private static void DrawCircleImage(Graphics g, Bitmap img, float x, float y, float box, float alpha) + { + img = CenteredSquare(img); + float inset = box * 0.19f, d = box - inset * 2; + var circle = new RectangleF(x + inset, y + inset, d, d); + int s = Math.Max(1, (int)Math.Ceiling(d)); + + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + sg.SmoothingMode = SmoothingMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = alpha }); + int side = Math.Min(img.Width, img.Height); + sg.DrawImage(img, new Rectangle(0, 0, s, s), + (img.Width - side) / 2, (img.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(circle.X, circle.Y); + var old = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + using (var p = new GraphicsPath()) { p.AddEllipse(circle); g.FillPath(tb, p); } + g.SmoothingMode = old; + } + + private static readonly System.Collections.Generic.Dictionary _centered = new(); + private static Bitmap CenteredSquare(Bitmap src) + { + lock (_centered) + { + if (_centered.TryGetValue(src, out var c)) return c; + var made = MakeCenteredSquare(src); + _centered[src] = made; + return made; + } + } + + private static Bitmap MakeCenteredSquare(Bitmap src) + { + try + { + var data = src.LockBits(new Rectangle(0, 0, src.Width, src.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + int minX = src.Width, minY = src.Height, maxX = -1, maxY = -1; + try + { + int stride = data.Stride; + var buf = new byte[stride * src.Height]; + System.Runtime.InteropServices.Marshal.Copy(data.Scan0, buf, 0, buf.Length); + for (int yy = 0; yy < src.Height; yy++) + for (int xx = 0; xx < src.Width; xx++) + if (buf[yy * stride + xx * 4 + 3] > 24) + { + if (xx < minX) minX = xx; if (xx > maxX) maxX = xx; + if (yy < minY) minY = yy; if (yy > maxY) maxY = yy; + } + } + finally { src.UnlockBits(data); } + if (maxX < minX) return src; + + int edge = Math.Max(1, Math.Min(src.Width, src.Height) / 64); + if (minX <= edge && maxX >= src.Width - 1 - edge) return src; + if (minY <= edge && maxY >= src.Height - 1 - edge) return src; + + float dx = (src.Width - 1) / 2f - (minX + maxX) / 2f; + float dy = (src.Height - 1) / 2f - (minY + maxY) / 2f; + if (Math.Abs(dx) < 1.5f && Math.Abs(dy) < 1.5f) return src; + + var shifted = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(shifted)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.DrawImage(src, (int)Math.Round(dx), (int)Math.Round(dy), src.Width, src.Height); + } + return shifted; + } + catch { return src; } + } + + internal static Bitmap BlurPyramid(Bitmap src) + { + int w = src.Width, h = src.Height; + using var s1 = new Bitmap(Math.Max(1, w / 14), Math.Max(1, h / 14), PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(s1)) + { + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.DrawImage(src, new Rectangle(0, 0, s1.Width, s1.Height), new Rectangle(0, 0, w, h), GraphicsUnit.Pixel); + } + + using var s2 = new Bitmap(Math.Max(1, w / 5), Math.Max(1, h / 5), PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(s2)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.DrawImage(s1, new Rectangle(0, 0, s2.Width, s2.Height), new Rectangle(0, 0, s1.Width, s1.Height), GraphicsUnit.Pixel); + } + var big = new Bitmap(w, h, PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(big)) + { + + if (FrostMix > 0.004f) + { + using var wash = new SolidBrush(Mean(s1)); + g.FillRectangle(wash, 0, 0, w, h); + } + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + using var ia = new ImageAttributes(); + var m = new ColorMatrix { Matrix33 = 1f - FrostMix }; + ia.SetColorMatrix(m); + g.DrawImage(s2, new Rectangle(0, 0, w, h), + 0, 0, s2.Width, s2.Height, GraphicsUnit.Pixel, ia); + } + return big; + } + + internal static float FrostMix = 0.55f; + + internal static float Sheen = 0f, Grain = 0f, RimLight = 0f; + + private static Bitmap? _grain; + + private static Bitmap GrainTile() + { + if (_grain is { } g0 && Math.Abs(_grainFor - Grain) < 0.0005f) return g0; + _grain?.Dispose(); + const int n = 128; + var bmp = new Bitmap(n, n, PixelFormat.Format32bppPArgb); + var data = bmp.LockBits(new Rectangle(0, 0, n, n), ImageLockMode.WriteOnly, PixelFormat.Format32bppPArgb); + try + { + var rnd = new Random(20260728); + int peak = (int)Math.Clamp(Grain * 255f, 0f, 255f); + unsafe + { + for (int y = 0; y < n; y++) + { + byte* row = (byte*)data.Scan0 + y * data.Stride; + for (int x = 0; x < n; x++) + { + + byte a = (byte)rnd.Next(peak + 1); + row[x * 4] = a; row[x * 4 + 1] = a; row[x * 4 + 2] = a; row[x * 4 + 3] = a; + } + } + } + } + finally { bmp.UnlockBits(data); } + _grainFor = Grain; + return _grain = bmp; + } + + private static float _grainFor = -1f; + + private static Color Mean(Bitmap b) + { + var data = b.LockBits(new Rectangle(0, 0, b.Width, b.Height), ImageLockMode.ReadOnly, + PixelFormat.Format32bppPArgb); + try + { + long r = 0, g = 0, bl = 0; + int n = b.Width * b.Height; + unsafe + { + for (int y = 0; y < b.Height; y++) + { + byte* row = (byte*)data.Scan0 + y * data.Stride; + for (int x = 0; x < b.Width; x++) + { + bl += row[x * 4]; g += row[x * 4 + 1]; r += row[x * 4 + 2]; + } + } + } + return Color.FromArgb(255, (int)(r / n), (int)(g / n), (int)(bl / n)); + } + finally { b.UnlockBits(data); } + } + + private static Bitmap Blur(Bitmap src, int factor) + { + int sw = Math.Max(1, src.Width / factor), sh = Math.Max(1, src.Height / factor); + var small = new Bitmap(sw, sh, PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(small)) + { + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.DrawImage(src, new Rectangle(0, 0, sw, sh), new Rectangle(0, 0, src.Width, src.Height), GraphicsUnit.Pixel); + } + var big = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppPArgb); + using (var g = Graphics.FromImage(big)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.DrawImage(small, new Rectangle(0, 0, src.Width, src.Height), new Rectangle(0, 0, sw, sh), GraphicsUnit.Pixel); + } + small.Dispose(); + return big; + } + + private static GraphicsPath PillPath(int w, int h, int r) + { + int d = r * 2; + var p = new GraphicsPath(); + p.AddLine(0, 0, w, 0); + p.AddArc(w - d, h - d, d, d, 0, 90); + p.AddArc(0, h - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private void HandleClipboard() + { + uint seq = Win32.GetClipboardSequenceNumber(); + if (seq == _lastClipSeq) return; + _lastClipSeq = seq; + long now = Environment.TickCount64; + if (now - _lastClipTick < 800) return; + if (!Win32.IsClipboardFormatAvailable(Win32.CF_BITMAP)) return; + bool shot = OwnerIsCapture(); + var bmp = ReadClipboardBitmap(); + if (bmp != null) { _lastClipTick = now; ClipboardImage?.Invoke(bmp, shot); } + } + + private static bool OwnerIsCapture() + { + try + { + IntPtr owner = Win32.GetClipboardOwner(); + if (owner == IntPtr.Zero) return true; + Win32.GetWindowThreadProcessId(owner, out uint pid); + if (pid == 0) return true; + using var p = System.Diagnostics.Process.GetProcessById((int)pid); + string pn = p.ProcessName.ToLowerInvariant(); + foreach (var s in SnipHosts) if (pn.Contains(s)) return true; + return false; + } + catch { return true; } + } + + private Bitmap? ReadClipboardBitmap() + { + if (!Win32.OpenClipboard(Hwnd)) return null; + try + { + IntPtr h = Win32.GetClipboardData(Win32.CF_BITMAP); + if (h == IntPtr.Zero) return null; + using var tmp = Image.FromHbitmap(h); + return new Bitmap(tmp); + } + catch { return null; } + finally { Win32.CloseClipboard(); } + } + + public Func? WantsHandCursor; + private static IntPtr _handCursor; + + private IntPtr WndProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam) + { + + if (msg == Win32.WM_SETCURSOR && WantsHandCursor is { } wantsHand) + { + try + { + if (Win32.GetCursorPos(out var cp) && wantsHand(new Point(cp.X, cp.Y))) + { + if (_handCursor == IntPtr.Zero) _handCursor = Win32.LoadCursor(IntPtr.Zero, Win32.IDC_HAND); + Win32.SetCursor(_handCursor); + return new IntPtr(1); + } + } + catch { } + } + + if (msg == Win32.WM_TIMECHANGE) + { + try { Almanac.TimeZoneChanged(); } catch { } + return IntPtr.Zero; + } + if (msg == Win32.WM_DESTROY) + { + Win32.PostQuitMessage(0); + return IntPtr.Zero; + } + if (msg == Win32.WM_CLIPBOARDUPDATE) + { + HandleClipboard(); + return IntPtr.Zero; + } + if (msg is Win32.WM_DISPLAYCHANGE or Win32.WM_SETTINGCHANGE) + { + + var work = default(Win32.RECT); + Win32.SystemParametersInfo(Win32.SPI_GETWORKAREA, 0, ref work, 0); + _workLeft = work.left; + _workTop = work.top; + _workWidth = work.right - work.left; + lock (_bgLock) { _bg?.Dispose(); _bg = null; } + } + return Win32.DefWindowProc(hwnd, msg, wParam, lParam); + } +} diff --git a/src/Halo.App/Shell/NotchController.Api.cs b/src/Halo.App/Shell/NotchController.Api.cs new file mode 100644 index 0000000..ba17245 --- /dev/null +++ b/src/Halo.App/Shell/NotchController.Api.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Halo.Api; +using Halo.ClaudeCode; +using Halo.Widgets; + +namespace Halo.Shell; + +internal sealed partial class NotchController : IHaloHost +{ + private readonly ConcurrentQueue _posted = new(); + + public bool Post(Action work) + { + _posted.Enqueue(work); + return true; + } + + private void DrainPosted() + { + int budget = 16; + while (budget-- > 0 && _posted.TryDequeue(out var work)) + { + try { work(); } catch { } + } + } + + public JsonObject State() + { + var state = new JsonObject(); + try + { + state["expanded"] = _progress > 0.5f; + state["pinned"] = Pinned(_pinned); + state["inCaptures"] = _recordable; + state["scale"] = Math.Round(_notch.Scale, 3); + state["offsetX"] = Math.Round(_offsetX, 1); + state["empty"] = _empty; + state["micInUse"] = Privacy.Mic; + state["cameraInUse"] = Privacy.Cam; + + var widgets = new JsonArray(); + foreach (var i in ActiveIndices()) + widgets.Add(new JsonObject + { + ["kind"] = _widgets[i].GetType().Name, + ["primary"] = i == _primary, + }); + state["active"] = widgets; + + var banner = _notif; + state["banner"] = banner is null + ? null + : new JsonObject { ["app"] = banner.App, ["title"] = banner.Title, ["body"] = banner.Body }; + + var ask = _ask; + state["question"] = ask is null ? null : new JsonObject + { + ["nonce"] = ask.Nonce, + ["question"] = ask.Question, + ["options"] = Labels(ask), + }; + } + catch { } + return state; + } + + private static JsonArray Labels(PendingAsk ask) + { + var labels = new JsonArray(); + foreach (var option in ask.Options) labels.Add(option.Label); + return labels; + } + + public JsonObject Media() + { + var slots = new JsonArray(); + try + { + for (int slot = 0; slot < MediaSessions.MaxSlots; slot++) + { + string app = _mediaSessions.SlotApp(slot); + if (app.Length == 0) continue; + var widget = Find(w => w.Slot == slot); + slots.Add(new JsonObject + { + ["slot"] = slot, + ["app"] = app, + ["title"] = widget?.TitleText, + ["artist"] = widget?.ArtistText, + ["playing"] = widget?.Playing ?? false, + + ["progress"] = Math.Round(widget?.RingProgress ?? -1f, 4), + }); + } + } + catch { } + return new JsonObject { ["sessions"] = slots }; + } + + public JsonObject Agents() + { + var sessions = new JsonArray(); + try + { + for (int slot = 0; slot < StatusStore.MaxSessions; slot++) + if (_claudeStore.SessionLive(slot) is { } cc) + sessions.Add(Agent("claude", cc)); + + if (_codexStore.Current is { } codex) + sessions.Add(new JsonObject + { + ["kind"] = "codex", + ["state"] = codex.State, + ["tool"] = codex.CurrentTool, + ["cwd"] = codex.Cwd, + ["pid"] = codex.Pid, + }); + } + catch { } + return new JsonObject + { + ["sessions"] = sessions, + ["claudeFiveHourPct"] = Pct(Halo.ClaudeCode.Limits.FiveHour), + ["claudeWeeklyPct"] = Pct(Halo.ClaudeCode.Limits.Week), + }; + } + + private static JsonNode? Pct(double fraction) + => fraction < 0 ? null : JsonValue.Create(Math.Round(fraction * 100, 1)); + + private static JsonObject Agent(string kind, CcStatus status) => new() + { + ["kind"] = kind, + ["name"] = status.Name, + ["state"] = status.State, + ["tool"] = status.CurrentTool, + ["target"] = status.ToolTarget, + ["cwd"] = status.Cwd, + ["pid"] = status.Pid, + }; + + public JsonObject Tray() + { + var files = new JsonArray(); + try { foreach (var path in FileTray.Paths()) files.Add(path); } catch { } + return new JsonObject { ["files"] = files }; + } + + public JsonObject Settings() + { + var values = new JsonObject(); + try { foreach (var (key, value) in _settings.Current.Values) values[key] = value; } catch { } + return new JsonObject { ["values"] = values }; + } + + public void Notify(NotifyRequest request) + { + try + { + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = request.App, + Title = request.Title, + Body = request.Body, + Code = request.Code, + LaunchPath = request.LaunchPath, + Kind = "api", + Duration = Math.Clamp(request.Seconds, 2, 30), + }); + } + catch { } + } + + public bool MediaControl(string action, int slot) + { + try + { + var session = _mediaSessions.Session(slot < 0 ? PrimaryMediaSlot() : slot); + if (session is null) return false; + switch (action) + { + case "play": _ = session.TryPlayAsync(); return true; + case "pause": _ = session.TryPauseAsync(); return true; + case "toggle": _ = session.TryTogglePlayPauseAsync(); return true; + case "next": _ = session.TrySkipNextAsync(); return true; + case "previous": _ = session.TrySkipPreviousAsync(); return true; + default: return false; + } + } + catch { return false; } + } + + private int PrimaryMediaSlot() + { + if (!_empty && _widgets[_primary] is MediaWidget primary) return primary.Slot; + for (int slot = 0; slot < MediaSessions.MaxSlots; slot++) + if (_mediaSessions.SlotApp(slot).Length > 0) return slot; + return 0; + } + + public bool Pill(string action) + { + switch (action) + { + case "expand": return Post(() => { _apiHold = true; }); + case "collapse": return Post(() => { _apiHold = false; }); + case "pin": return Post(() => { if (!_pinned) { _pinned = true; SavePin(); } }); + case "unpin": return Post(() => { if (_pinned) { _pinned = false; SavePin(); } }); + case "recenter": return Post(() => { _offsetX = 0; SaveOffset(); }); + default: return false; + } + } + + private bool _apiHold; + + public int TrayAdd(IReadOnlyList paths) + { + int added = 0; + foreach (var path in paths) + { + try + { + if (!System.IO.File.Exists(path) && !System.IO.Directory.Exists(path)) continue; + FileTray.Add(path); + added++; + } + catch { } + } + return added; + } + + public int SettingsPatch(JsonObject values) + { + int written = 0; + foreach (var (key, node) in values) + { + try + { + if (node is not JsonValue value) continue; + string text = value.TryGetValue(out var s) ? s : value.ToJsonString().Trim('"'); + if (_settings.Set(key, text)) written++; + } + catch { } + } + return written; + } + + private T? Find(Func match) where T : class, IWidget + { + foreach (var widget in _widgets) + if (widget is T typed && match(typed)) return typed; + return null; + } +} diff --git a/src/Halo.App/Shell/NotchController.cs b/src/Halo.App/Shell/NotchController.cs new file mode 100644 index 0000000..0e7e8b5 --- /dev/null +++ b/src/Halo.App/Shell/NotchController.cs @@ -0,0 +1,2481 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using Halo.ClaudeCode; +using Halo.Codex; +using Halo.Interop; +using Halo.Widgets; +using Windows.System; + +namespace Halo.Shell; + +internal enum NotchVisibilityAction +{ + None, + Hide, + ShowAndRender, +} + +internal readonly record struct NotchVisibilityDecision( + NotchVisibilityAction Action, + bool ReturnEarly, + bool HiddenForFullscreen); + +internal static class NotchVisibility +{ + + internal static NotchVisibilityDecision Decide(bool fullscreen, bool hiddenForFullscreen) + { + if (fullscreen) + return new(hiddenForFullscreen ? NotchVisibilityAction.None : NotchVisibilityAction.Hide, + ReturnEarly: true, HiddenForFullscreen: true); + + return new(hiddenForFullscreen ? NotchVisibilityAction.ShowAndRender : NotchVisibilityAction.None, + ReturnEarly: false, HiddenForFullscreen: false); + } +} + +internal sealed class AgentNoticeCoordinator +{ + private readonly Dictionary _previous = new(); + private readonly Dictionary _pending = new(); + private long _nextOrder; + private int _restore = -1; + + internal AgentNoticeCoordinator(int primary) => Primary = primary; + + internal int Primary { get; private set; } + + internal bool IsOpen(DateTimeOffset now) => _pending.Values.Any(window => window.Until >= now); + + internal void SetPrimary(int primary) + { + if (_restore < 0) + Primary = primary; + } + + internal void Observe(int widgetIndex, AgentNotice notice, DateTimeOffset now, + bool desktopBacked = false, bool allowSelection = true) + { + _previous.TryGetValue(widgetIndex, out var previous); + _previous[widgetIndex] = notice; + + bool started = notice.State == "working" && previous.State != "working"; + + bool compacted = notice.CompactedAt is { } doneAt && doneAt != previous.CompactedAt && + now - doneAt < TimeSpan.FromSeconds(30); + + if (compacted) + _pending[widgetIndex] = new NoticeWindow(now.AddSeconds(4), desktopBacked, _nextOrder++); + + if (started && allowSelection && _pending.Count == 0 && _restore < 0) + Primary = widgetIndex; + + if (allowSelection) + Select(now, static _ => true); + } + + internal void Tick(DateTimeOffset now, Func? isActive = null, bool allowSelection = true) + { + foreach (var (index, window) in _pending.ToArray()) + if (window.Until < now) + _pending.Remove(index); + + if (allowSelection) + Select(now, isActive ?? (static _ => true)); + } + + private void Select(DateTimeOffset now, Func isActive) + { + if (_pending.Count > 0) + { + if (_restore < 0) + _restore = Primary; + + Primary = _pending + .OrderBy(pair => pair.Key == _restore ? 0 : pair.Value.DesktopBacked ? 1 : 2) + .ThenBy(pair => pair.Value.Order) + .First().Key; + return; + } + + if (_restore >= 0) + { + if (isActive(_restore)) + Primary = _restore; + _restore = -1; + } + } + + private readonly record struct NoticeWindow(DateTimeOffset Until, bool DesktopBacked, long Order); +} + +internal sealed partial class NotchController +{ + private const int CollapsedW = 220, CollapsedH = 40, CollapsedR = 20; + private const int ExpandedW = 560, ExpandedH = 220, ExpandedR = 30; + private const int TintDeskCollapsed = 255; + internal const int TintDeskExpanded = 245; + + internal const int TintAppCollapsed = 120, TintAppExpanded = 48; + + internal const int TintAskDesk = 60, TintAskApp = 34; + + internal const float BannerClarity = 0.8f; + private const float OpenSeconds = 0.30f, CloseSeconds = 0.38f; + + private const float HoldSeconds = 0.75f; + + private const int CaptureOpenMs = 16, CaptureCollapsedMs = 50; + private const int EmptyCatchAlpha = 1; + + private readonly LayeredNotch _notch; + private readonly StatusStore _claudeStore; + private readonly CodexStatusStore _codexStore; + private readonly CodexDesktopRuntime _codexDesktopRuntime; + private readonly IWidget[] _widgets; + private readonly MediaSessions _mediaSessions; + private readonly AgentNoticeCoordinator _agentNotices; + private readonly DispatcherQueueTimer _timer; + + private float S => _notch.Scale; + private int Sc(int v) => (int)MathF.Round(v * S); + private int _cl => _notch.WorkLeft + (_notch.WorkWidth - Sc(CollapsedW)) / 2 + (int)_offsetX; + private int _el => _notch.WorkLeft + (_notch.WorkWidth - Sc(ExpandedW)) / 2 + (int)_offsetX; + private int _ct => _notch.WorkTop; + private int _et => _notch.WorkTop; + + private int _primary; + private int _userPicked = -1; + private float _progress; + private float _menu; + private float _drop = -1f; + private float _arrive = -1f; + private int _pending; + private float _dropCX, _dropCY; + private bool _dropOut; + private string _dropIcon = ""; + private Bitmap? _dropImage; + private readonly bool[] _prevActive; + private int _row = -1; + private float _rowOpen; + private float _stripT; + private int _widgetVersion = -1; + private int _lastSec = -1; + private bool _lastMouseDown; + private bool _prevDragActive; + private long _trayShowUntil; + + private string? _trayPressPath; + private Win32.POINT _trayPressAt; + private int _trayMode = -1; + private bool _lastTrayDown; + private bool _resizing; + private Win32.POINT _resizeFrom; + private float _scale0, _handle; + private bool _hiddenForFullscreen; + + private float _offsetX; + private bool _moving; + private float _holdT; + private DateTime _holdStart = DateTime.MaxValue; + private Win32.POINT _holdAnchor; + private int _moveGrabDX; + private bool _pinned; + + private static bool Pinned(bool userPin) => userPin || FileTray.Holding; + + private bool TrayFront => FileTray.DragActive || (!_empty && _widgets[_primary] is FileTray); + private float _pinHov; + private float _shrink; + private bool _empty; + + private readonly Halo.Notifications.NotifSource _notifSrc = new(); + private Halo.Notifications.BtBattery? _bt; + private readonly Widgets.BtWidget _btWidget = new(); + private System.Threading.Timer? _testTrigger; + private Halo.Notifications.NotifItem? _notif; + + private readonly AskStore _asks; + private PendingAsk? _ask; + private float _askT; + private int _askH = 120; + private int _askHover = -1; + private System.Collections.Generic.List<(RectangleF Rect, Halo.ClaudeCode.AskOption Option)> _askChips = []; + + private string? _askTyped; + private string? _drawnTyped; + private string _askDraft = ""; + private string? _askDraftNonce; + + private GreetingKind _greet; + private float _greetT; + + private readonly StripOrder _stripOrder = StripOrder.Load(StripOrderPath); + private List _stripKinds = []; + private int _dragRow = -1; + private float _dragFromY; + private float _dragHeld; + private float _carryDY; + private float _carryWant; + private float _drawnCarryDY; + private int _drawnDragRow = -1; + private float[] _rowShift = []; + private readonly Halo.Interop.KeyGrab _keys = new(); + + private float _notifT; + private bool _notifClosing; + private bool _notifDetailOn; + private float _notifDetail; + private int _notifDetailH = NotifBanner.SummaryH + 60; + private DateTime _notifDeadline; + private int _curW = CollapsedW, _curH = CollapsedH; + private bool _lastDesktop = true; + private IntPtr _lastFg = IntPtr.Zero; + private uint _lastLangId; + private IntPtr _langFg; + private long _langFgSince; + private IntPtr _behind = IntPtr.Zero; + private long _lastCaptureAt; + private int _animTick; + private int _lastCaptureVer; + + private long _alertAt; + + private readonly Dictionary _limitFired = LoadLimitFired(); + private static readonly string LimitFiredPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "limit-fired.txt"); + + private static Dictionary LoadLimitFired() + { + var d = new Dictionary(); + try + { + foreach (var line in System.IO.File.ReadAllLines(LimitFiredPath)) + { + var p = line.Split('|'); + if (p.Length == 3 && DateTimeOffset.TryParse(p[1], out var r) && DateTime.TryParse(p[2], null, + System.Globalization.DateTimeStyles.AdjustToUniversal, out var a)) + d[p[0]] = (r, a); + } + } + catch { } + return d; + } + + private void SaveLimitFired() + { + try + { + var lines = new List(); + foreach (var (k, v) in _limitFired) lines.Add($"{k}|{v.reset:o}|{v.at:o}"); + System.IO.File.WriteAllLines(LimitFiredPath, lines); + } + catch { } + } + public NotchController(LayeredNotch notch) + { + _notch = notch; + _notch.ClipboardImage += OnClipboardImage; + _notch.WantsHandCursor = OverPressable; + _claudeStore = new StatusStore(); + + _asks = new AskStore(System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "notch")); + _claudeStore.AfterLoad = _asks.Rescan; + _asks.Rescan(); + _keys.OnChar = TypedChar; + _keys.OnKey = TypedKey; + + _greet = GreetingGate.Read(GreetedPath); + GreetingGate.Mark(GreetedPath); + _codexStore = new CodexStatusStore(); + _codexDesktopRuntime = CodexDesktopRuntime.Shared; + CodexLimits.Attach(_codexStore); + CodexLimits.UpdateFrom(_codexStore.Current); + + _settings = new Halo.Settings.SettingsStore(); + Halo.Settings.SettingsStore.Shared = _settings; + _appliedSettings = _settings.Version; + _api = new Halo.Api.HaloApi(ApiConfig, this); + _api.Reconcile(); + _startupApplied = _settings.Current.Bool(Halo.Settings.SettingsKeys.StartWithWindows, true); + _silenceApplied = _settings.Current.Bool("notifications.silence", false); + if (_silenceApplied) System.Threading.ThreadPool.QueueUserWorkItem( + _ => { try { Halo.Notifications.BannerGate.Enable(); } catch { } }); + + _mediaSessions = new MediaSessions(); + var widgets = new List(); + for (int s = 0; s < MediaSessions.MaxSlots; s++) + widgets.Add(new MediaWidget(_mediaSessions, s)); + widgets.Add(new VlcWidget(_mediaSessions)); + widgets.Add(new DownloadWidget()); + widgets.Add(new FileTray()); + widgets.Add(_btWidget); + Privacy.Poke(); + for (int s = 0; s < StatusStore.MaxSessions; s++) + { + int slot = s; + widgets.Add(new ClaudeCodeWidget(_claudeStore, slot, () => CancelClaude(slot))); + } + widgets.Add(new CodexWidget(_codexStore, CodexSurface.Desktop, () => CancelCodex(CodexSurface.Desktop), + () => _codexDesktopRuntime.Presence.Running)); + widgets.Add(new CodexWidget(_codexStore, CodexSurface.Cli, () => CancelCodex(CodexSurface.Cli))); + var agentStore = GenericAgentWidget.NewStore(); + for (int s = 0; s < StatusStore.MaxSessions; s++) + widgets.Add(new GenericAgentWidget(agentStore, s)); + _widgets = [.. widgets]; + + var active = ActiveIndices(); + LoadOffset(); + LoadRecordable(); + _notch.SetCapturable(_recordable); + _empty = active.Length == 0; + _shrink = _empty ? 1f : 0f; + if (!_empty) _primary = active[0]; + _prevActive = new bool[_widgets.Length]; + for (int i = 0; i < _widgets.Length; i++) _prevActive[i] = Live(i); + Apply(0f); + _agentNotices = new AgentNoticeCoordinator(_primary); + + _bt = new Halo.Notifications.BtBattery((name, pct) => _btWidget.Show(name, pct)); + _testTrigger = new System.Threading.Timer(_ => PollTestNotif(), null, 1000, 1000); + + Dispatcher.Ensure(); + var dq = DispatcherQueue.GetForCurrentThread(); + _timer = dq.CreateTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(8); + _timer.Tick += OnTick; + _timer.Start(); + } + + private void OnTick(DispatcherQueueTimer sender, object args) + { + + try { Frame(); } catch (Exception ex) { CrashLog(ex); } + } + + private static void CrashLog(Exception ex) + { + try + { + var p = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "frame-errors.txt"); + System.IO.File.WriteAllText(p, $"{DateTime.Now:HH:mm:ss}\n{ex}"); + } + catch { } + } + + private long _cpuIdle, _cpuBusyBase, _cpuAt; + private int _fps = 120; + + private bool _heavy; + + private static readonly int[] CpuTiers = { 50, 70, 85, 95 }; + private static readonly int[] RamTiers = { 70, 85, 95 }; + + internal static int[] Tiers(int[] fixedTiers, int first) + { + var tiers = new List { first }; + foreach (var tier in fixedTiers) if (tier > first) tiers.Add(tier); + return [.. tiers]; + } + private int _cpuTierFired = -1, _ramTierFired = -1; + private int _cpuStreak, _ramStreak; + internal bool Heavy => _heavy; + private void AdaptFrameRate() + { + long now = Environment.TickCount64; + if (now - _cpuAt < 1000) return; + _cpuAt = now; + if (!Win32.GetSystemTimes(out long idle, out long kern, out long user)) return; + long total = kern + user; + + bool watching = _progress > 0.02f || _notif != null || _drop >= 0f; + int target = _fps; + if (_cpuBusyBase != 0 && total > _cpuBusyBase) + { + float busy = 1f - (float)(idle - _cpuIdle) / (total - _cpuBusyBase); + + if (watching) target = 60; + else if (busy > 0.90f) target = 30; + else if (busy > 0.55f) target = 60; + else if (busy < 0.45f) target = 120; + + bool heavy = !watching && (_heavy ? busy > 0.40f : busy > 0.50f); + if (heavy != _heavy) + { + _heavy = heavy; + try { System.Diagnostics.Process.GetCurrentProcess().PriorityClass = + heavy ? System.Diagnostics.ProcessPriorityClass.BelowNormal + : System.Diagnostics.ProcessPriorityClass.Normal; } catch { } + } + int pctNow = (int)(busy * 100); + int tier = TierOf(Tiers(CpuTiers, Halo.Settings.SettingsStore.Percent("alert.cpuAt", 50)), pctNow); + _cpuStreak = tier > _cpuTierFired ? _cpuStreak + 1 : 0; + + if (_cpuStreak >= 10) + { + _cpuTierFired = tier; _cpuStreak = 0; + if (Alert("cpu")) QueueCpuNotice(pctNow); + } + CheckRam(); + } + _cpuIdle = idle; _cpuBusyBase = total; + if (target != _fps) + { + _fps = target; + _timer.Interval = TimeSpan.FromMilliseconds(target >= 120 ? 8 : target >= 60 ? 16 : 33); + } + } + + private static int TierOf(int[] tiers, int pct) + { + int t = -1; + for (int i = 0; i < tiers.Length; i++) if (pct >= tiers[i]) t = i; + return t; + } + + private void CheckRam() + { + var ms = new Win32.MEMORYSTATUSEX { dwLength = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (!Win32.GlobalMemoryStatusEx(ref ms)) return; + int pct = (int)ms.dwMemoryLoad; + int tier = TierOf(Tiers(RamTiers, Halo.Settings.SettingsStore.Percent("alert.memoryAt", 70)), pct); + _ramStreak = tier > _ramTierFired ? _ramStreak + 1 : 0; + if (_ramStreak >= 10) + { + _ramTierFired = tier; _ramStreak = 0; + if (Alert("memory")) QueueRamNotice(pct); + } + } + + private void QueueRamNotice(int pct) + => QueueLoadNotice("memory", pct, TopRamProcess, "Memory is running low."); + + private void QueueLoadNotice(string resource, int pct, Func topProcess, string? fallbackBody) + { + bool cpu = resource == "CPU"; + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + string? top = topProcess(); + string? body = top != null ? $"{top} is using the most." : fallbackBody; + if (body == null) return; + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = "System", Title = $"High {resource} usage — {pct}%", + Body = body, Kind = cpu ? "cpu" : "memory", Duration = 7, + Icon = cpu ? Badges.Cpu() : Badges.Memory(), + }); + }); + } + + private static string? TopRamProcess() + { + try + { + var procs = System.Diagnostics.Process.GetProcesses(); + string? best = null; long bestWs = 0; int self = Environment.ProcessId; + foreach (var p in procs) + { + try { if (p.Id != self && p.Id > 4 && p.WorkingSet64 > bestWs) { bestWs = p.WorkingSet64; best = p.ProcessName; } } + catch { } + } + foreach (var p in procs) { try { p.Dispose(); } catch { } } + return best == null || best.Length == 0 ? null : char.ToUpperInvariant(best[0]) + best[1..]; + } + catch { return null; } + } + + private void QueueCpuNotice(int sysPct) + => QueueLoadNotice("CPU", sysPct, TopCpuProcess, null); + + private static string? TopCpuProcess() + { + try + { + var procs = System.Diagnostics.Process.GetProcesses(); + var t0 = new Dictionary(); + foreach (var p in procs) { try { t0[p.Id] = p.TotalProcessorTime; } catch { } } + System.Threading.Thread.Sleep(450); + string? best = null; double bestMs = 0; int self = Environment.ProcessId; + foreach (var p in procs) + { + try + { + if (p.Id == self || p.Id <= 4 || !t0.TryGetValue(p.Id, out var a)) continue; + p.Refresh(); + double ms = (p.TotalProcessorTime - a).TotalMilliseconds; + if (ms > bestMs) { bestMs = ms; best = p.ProcessName; } + } + catch { } + } + foreach (var p in procs) { try { p.Dispose(); } catch { } } + return best == null || best.Length == 0 ? null : char.ToUpperInvariant(best[0]) + best[1..]; + } + catch { return null; } + } + + private bool Alert(string name, bool on = true) => _settings.Current.Bool("alert." + name, on); + + private int _appliedSettings = -1; + private bool _startupApplied; + private bool _silenceApplied; + private readonly Halo.Api.HaloApi _api; + + private Halo.Api.HaloApi.Config ApiConfig() + { + var current = _settings.Current; + bool on = current.Bool("api.enabled", false); + string token = current.Text("api.token", ""); + if (on && token.Length == 0) + { + token = Guid.NewGuid().ToString("n"); + _settings.Set("api.token", token); + } + return new Halo.Api.HaloApi.Config( + on, + int.TryParse(current.Text("api.port", ""), out var port) && port is > 1023 and < 65536 + ? port : Halo.Api.HaloApi.DefaultPort, + token, + current.Bool("api.notify", true), + current.Bool("api.ask", true), + current.Bool("api.state", false), + + current.Bool("api.control", false), + current.Bool("api.settings", false)); + } + + private void SyncSettings() + { + int version = _settings.Version; + if (version == _appliedSettings) return; + _appliedSettings = version; + var current = _settings.Current; + + bool startup = current.Bool(Halo.Settings.SettingsKeys.StartWithWindows, true); + if (startup != _startupApplied) + { + _startupApplied = startup; + Autostart(startup); + } + + bool pin = current.Bool(Halo.Settings.SettingsKeys.OverFullscreen, _pinned); + if (pin != _pinned) + { + _pinned = pin; + try { System.IO.File.WriteAllText(PinPath, _pinned ? "1" : "0"); } catch { } + } + + _api.Reconcile(); + + bool silence = current.Bool("notifications.silence", false); + if (silence != _silenceApplied) + { + _silenceApplied = silence; + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { if (silence) Halo.Notifications.BannerGate.Enable(); else Halo.Notifications.BannerGate.Restore(); } + catch { } + }); + } + + bool recordable = current.Bool(Halo.Settings.SettingsKeys.InCaptures, _recordable); + if (recordable != _recordable) + { + _recordable = recordable; + try { System.IO.File.WriteAllText(RecordablePath, _recordable ? "1" : "0"); } catch { } + try { _notch.SetCapturable(_recordable); } catch { } + } + + if (Scale(current.Text(Halo.Settings.SettingsKeys.Scale, "")) is { } scale + && Math.Abs(scale - _notch.Scale) > 0.001f) + { + _notch.Scale = scale; + try { _notch.SaveScale(); } catch { } + } + } + + private static void Autostart(bool on) + { + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try + { + string hooks = System.IO.Path.Combine(AppContext.BaseDirectory, "Halo.Hooks.exe"); + if (!System.IO.File.Exists(hooks)) return; + var psi = new System.Diagnostics.ProcessStartInfo(hooks) + { + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add(on ? "install-autostart" : "uninstall-autostart"); + if (on) psi.ArgumentList.Add(Environment.ProcessPath ?? ""); + using var p = System.Diagnostics.Process.Start(psi); + p?.WaitForExit(20_000); + } + catch { } + }); + } + + private float MotionScale => _settings.Current.Text(Halo.Settings.SettingsKeys.Motion, "Soft") switch + { + "Reduced" => 0.35f, + "Standard" => 1.55f, + _ => 1f, + }; + + private float GlassScale => _settings.Current.Text(Halo.Settings.SettingsKeys.Glass, "Balanced") switch + { + "Light" => 0.66f, + "Strong" => 1.34f, + _ => 1f, + }; + + private static float? Scale(string text) + { + if (text.Length == 0) return null; + string digits = text.TrimEnd('%'); + return float.TryParse(digits, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var pct) + ? Math.Clamp(pct / 100f, 0.7f, 1.6f) + : null; + } + + private void CheckAlerts() + { + long now = Environment.TickCount64; + if (now - _alertAt < 1000) return; + _alertAt = now; + SyncSettings(); + if (Pinned(_pinned)) _notch.AssertTopmost(); + + ReloadOffset(); + if (Alert("battery")) CheckBattery(); + if (Alert("limit")) + { + CheckLimit("Claude", ClaudeCode.Limits.FiveHour, ClaudeCode.Limits.FiveHourReset, "5-hour"); + CheckLimit("Claude", ClaudeCode.Limits.Week, ClaudeCode.Limits.WeekReset, "weekly"); + + CheckLimit("Codex", CodexLimits.PrimaryFrac, CodexLimits.PrimaryReset, "primary"); + CheckLimit("Codex", CodexLimits.SecondaryFrac, CodexLimits.SecondaryReset, "secondary"); + } + if (Alert("internet")) CheckInternet(); + if (Alert("context")) CheckContext(); + CheckCompact(); + if (Alert("hourly", on: false)) CheckHourly(); + Almanac.Poke(); + } + + private readonly HashSet _ctxWarned = new(StringComparer.Ordinal); + private readonly List _ctxLive = new(); + + private void CheckContext() + { + _ctxLive.Clear(); + foreach (var widget in _widgets) + { + if (widget is not Widgets.ClaudeCodeWidget cc) continue; + var (id, frac) = cc.ContextState(); + if (id is null) continue; + _ctxLive.Add(id); + if (frac < Widgets.ClaudeCodeWidget.ContextWarnAt) + { + _ctxWarned.Remove(id); + continue; + } + if (!_ctxWarned.Add(id)) continue; + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = "Claude", Title = $"Context {(int)(frac * 100)}% full", + Body = "Answers get vaguer from here — /compact when you can.", + Kind = "ctx-" + id, Duration = 8, Icon = Badges.Context(), + }); + } + + if (_ctxWarned.Count > _ctxLive.Count) _ctxWarned.IntersectWith(_ctxLive); + } + + private void CheckCompact() + { + int pid = 0; + string? key = null; + for (int s = 0; s < StatusStore.MaxSessions && pid == 0; s++) + + if (_claudeStore.SessionLive(s) is { State: "compacting", Pid: > 0 } st + && Widgets.ClaudeCodeWidget.Compacting(st)) + { + pid = st.Pid; + key = st.StartedAt; + } + if (pid > 0) ClaudeCode.CompactProgress.Poke(pid, key); + else ClaudeCode.CompactProgress.Done(); + } + + private int _chimedHour = DateTime.Now.Hour; + private void CheckHourly() + { + Almanac.SyncZone(); + var t = DateTime.Now; + if (t.Minute != 0 || t.Hour == _chimedHour) return; + _chimedHour = t.Hour; + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = Almanac.Label, Title = Almanac.Headline(t), Body = Almanac.Detail(t), + Kind = "hourly", Duration = 6, Icon = Badges.Hourly(), + }); + } + + private static readonly string TestNotifPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "notif-test.txt"); + private void PollTestNotif() + { + try + { + if (!System.IO.File.Exists(TestNotifPath)) return; + var line = System.IO.File.ReadAllText(TestNotifPath).Trim(); + System.IO.File.Delete(TestNotifPath); + if (line.Length == 0) return; + var parts = line.Split('|'); + string type = parts[0].Trim().ToLowerInvariant(); + string arg = parts.Length > 1 ? parts[1].Trim() : ""; + string proc = parts.Length > 2 && parts[2].Trim().Length > 0 ? parts[2].Trim() : ""; + switch (type) + { + + case "cpu": case "sys": case "system": + QueueLoadNotice("CPU", int.TryParse(arg, out var cp) ? cp : 92, + () => proc.Length > 0 ? proc : TopCpuProcess() ?? "Chrome", null); + break; + case "ram": case "mem": case "memory": + QueueLoadNotice("memory", int.TryParse(arg, out var rp) ? rp : 88, + () => proc.Length > 0 ? proc : TopRamProcess() ?? "Chrome", null); + break; + case "clock": case "hour": case "hourly": + var t = int.TryParse(arg, out var hr) && hr is >= 0 and <= 23 ? DateTime.Today.AddHours(hr) : DateTime.Now; + Almanac.Poke(); + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = Almanac.Label, Title = Almanac.Headline(t), Body = Almanac.Detail(t), + Kind = "hourly", Duration = 6, Icon = Badges.Hourly(), + }); + break; + } + } + catch { } + } + + private static readonly int[] BatteryTiers = [20, 10]; + + private static int[] BatteryLadder() + { + int low = Halo.Settings.SettingsStore.Percent("alert.batteryAt", 20); + return low <= 10 ? [low] : [low, 10]; + } + private int _battTier = -1; + + private void CheckBattery() + { + if (!Win32.GetSystemPowerStatus(out var s)) return; + bool onBattery = s.ACLineStatus == 0; + int pct = s.BatteryLifePercent; + if (!onBattery || pct > 100) { _battTier = -1; return; } + int tier = BatteryTier(pct, BatteryLadder()); + if (tier <= _battTier) { if (tier < 0) _battTier = -1; return; } + _battTier = tier; + bool dead = tier >= 1; + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = "Battery", Title = $"Battery {(dead ? "critical" : "low")} — {pct}%", + Body = "Tap to turn on Power Saver.", + Kind = "battery", Duration = 8, OnActivate = EnablePowerSaver, + Icon = dead ? Badges.BatteryDead() : Badges.BatteryLow(), + }); + } + + internal static int BatteryTier(int pct) => BatteryTier(pct, BatteryTiers); + + internal static int BatteryTier(int pct, int[] ladder) + { + int t = -1; + for (int i = 0; i < ladder.Length; i++) if (pct <= ladder[i]) t = i; + return t; + } + + private static void EnablePowerSaver() + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "powercfg", Arguments = "/setactive a1841308-3541-4fab-bc81-f71556f20b4a", + UseShellExecute = false, CreateNoWindow = true, + }); + } + catch { } + } + + private void CheckLimit(string app, float util, DateTimeOffset reset, string window) + { + if (util < Halo.Settings.SettingsStore.Percent("alert.limitAt", 80) / 100f) return; + string key = app + window; + if (_limitFired.TryGetValue(key, out var f) + && (DateTime.UtcNow - f.at < TimeSpan.FromHours(6) + || (reset != default && f.reset != default && (reset - f.reset).Duration() < TimeSpan.FromMinutes(30)))) + return; + _limitFired[key] = (reset, DateTime.UtcNow); + SaveLimitFired(); + int p = (int)(util * 100); + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = app, Title = $"{app} usage {p}%", Body = $"You've used {p}% of your {window} limit.", + Kind = $"limit-{app}-{window}", Duration = 8, + Icon = LongWindow(window) ? Badges.LimitLong() : Badges.Limit(), + }); + } + + internal static bool LongWindow(string window) => window is "weekly" or "secondary"; + + private string? _netShown; + private void CheckInternet() + { + var trouble = NetTrouble(ClaudeCode.NetMon.NetDown, ClaudeCode.NetMon.ApiDown, ClaudeCode.NetMon.Slow); + if (trouble == _netShown) return; + _netShown = trouble; + if (trouble is null) return; + var item = trouble switch + { + "offline" => new Halo.Notifications.NotifItem + { + App = "Network", Title = "No internet", Body = "Nothing is getting out right now.", + Kind = "net", Duration = 7, Icon = Badges.NetDown(), + }, + "api" => new Halo.Notifications.NotifItem + { + App = "Claude", Title = "Claude is unreachable", Body = "Your connection is fine — theirs isn't.", + Kind = "net", Duration = 7, Icon = Badges.ApiDown(), + }, + _ => new Halo.Notifications.NotifItem + { + App = "Network", Title = "Bad internet", Kind = "net", Duration = 6, Icon = Badges.NetSlow(), + }, + }; + _notifSrc.EnqueueLocal(item); + } + + internal static string? NetTrouble(bool netDown, bool apiDown, bool slow) + => netDown ? "offline" : apiDown ? "api" : slow ? "slow" : null; + + internal static readonly TimeSpan WakeGap = TimeSpan.FromSeconds(90); + private DateTime _lastTickUtc = DateTime.UtcNow; + + private void CheckWake() + { + var now = DateTime.UtcNow; + var gap = now - _lastTickUtc; + _lastTickUtc = now; + + if (gap < WakeGap || _greet != GreetingKind.None || _notif != null || _ask != null) return; + _greet = GreetingKind.Login; + _greetT = 0f; + } + + private float _dt = 0.008f; + private long _lastFrameAt; + private void Frame() + { + DrainPosted(); + + long frameNow = Environment.TickCount64; + _dt = _lastFrameAt == 0 ? 0.008f : Math.Clamp((frameNow - _lastFrameAt) / 1000f, 0.001f, 0.05f); + _lastFrameAt = frameNow; + AdaptFrameRate(); + EaseRings(); + CheckAlerts(); + var notifStart = _notif; + var fg = Win32.GetForegroundWindow(); + DetectAgentCancel(fg); + DetectLanguageChange(fg); + + bool fullscreen = !Pinned(_pinned) && _notch.IsFullscreen(fg); + var active = fullscreen ? [] : ActiveIndices(); + + bool notifLive = _notif != null || _notifSrc.HasPending; + var visibility = NotchVisibility.Decide(fullscreen && !notifLive, _hiddenForFullscreen); + _hiddenForFullscreen = visibility.HiddenForFullscreen; + + if (visibility.Action == NotchVisibilityAction.Hide) + _notch.SetVisible(false); + else if (visibility.Action == NotchVisibilityAction.ShowAndRender) + { + if (active.Length > 0 && Array.IndexOf(active, _primary) < 0) + { + _primary = active[0]; + _agentNotices.SetPrimary(_primary); + } + _notch.SetVisible(true); + _lastFg = IntPtr.Zero; + Apply(_progress); + } + + if (visibility.ReturnEarly) + return; + + bool wasEmpty = _empty; + _empty = active.Length == 0; + + if (!_empty && _drop < 0f && Array.IndexOf(active, _primary) < 0) + { + _primary = active[0]; + _agentNotices.SetPrimary(_primary); + } + + var now = DateTimeOffset.UtcNow; + for (int i = 0; i < _widgets.Length; i++) + { + bool desktopBacked = _widgets[i] is CodexWidget codex && codex.IsDesktop; + _agentNotices.Observe(i, _widgets[i].AgentNotice, now, desktopBacked, allowSelection: _drop < 0f); + } + _agentNotices.Tick(now, Live, allowSelection: _drop < 0f); + if (_drop < 0f) + _primary = _agentNotices.Primary; + if (!_empty && Array.IndexOf(active, _primary) < 0) + { + _primary = active[0]; + _agentNotices.SetPrimary(_primary); + } + + if (_userPicked >= 0 && Array.IndexOf(active, _userPicked) < 0) _userPicked = -1; + + if (_drop < 0f && !_empty && _userPicked < 0 && _widgets[_primary].AgentNotice.State != "working") + foreach (var i in active) + if (_widgets[i] is ClaudeCodeWidget && _widgets[i].AgentNotice.State == "working") + { + _primary = i; + _agentNotices.SetPrimary(i); + break; + } + + if (_drop < 0f && !_empty && active.Length > 1 && _primary != _userPicked + && _widgets[_primary] is ClaudeCodeWidget) + { + Win32.GetWindowThreadProcessId(fg, out uint fpid); + if (fpid != 0 && FgHostsWidget((int)fpid, _primary)) + foreach (var i in active) + if (i != _primary && !FgHostsWidget((int)fpid, i)) + { + _primary = i; + _agentNotices.SetPrimary(i); + break; + } + } + bool notice = _drop < 0f && _agentNotices.IsOpen(now); + + if (_drop < 0f && !_empty && _userPicked < 0 && !notice) + for (int i = 0; i < _widgets.Length; i++) + if (_widgets[i] is DownloadWidget && Live(i)) + { _primary = i; _agentNotices.SetPrimary(i); break; } + + if (_drop < 0f && _btWidget.IsActive && _settings.Enabled(Halo.Settings.FeatureId.Bluetooth)) + for (int i = 0; i < _widgets.Length; i++) + if (_widgets[i] is BtWidget) { _primary = i; _agentNotices.SetPrimary(i); break; } + + if (_prevDragActive && !FileTray.DragActive) _trayShowUntil = Environment.TickCount64 + 2500; + _prevDragActive = FileTray.DragActive; + if (_drop < 0f && (FileTray.DragActive || Environment.TickCount64 < _trayShowUntil) + && _settings.Enabled(Halo.Settings.FeatureId.FileTray)) + for (int i = 0; i < _widgets.Length; i++) + if (_widgets[i] is FileTray) + { _primary = i; _agentNotices.SetPrimary(i); break; } + + for (int i = 0; i < _widgets.Length; i++) + { + bool isAct = Live(i); + if (isAct && !_prevActive[i] && !fullscreen && _drop < 0f) + { + if (i == _primary) _arrive = 0f; + else if (_progress < 0.1f) + { + _pending = _primary; + _dropOut = true; + _dropIcon = _widgets[i].Icon; + _dropImage = _widgets[i].IconImage; + _dropCX = _dropCY = LayeredNotch.CircleD / 2f; + _drop = 0f; + } + } + _prevActive[i] = isAct; + } + + Win32.GetCursorPos(out var p); + + float prevGreetT = _greetT; + var prevGreet = _greet; + CheckWake(); + if (_greet != GreetingKind.None) + { + if (_asks.Pending != null) { _greet = GreetingKind.None; _greetT = 0f; } + else + { + float secs = _greet == GreetingKind.Install + ? GreetingPlan.InstallSeconds : GreetingPlan.LoginSeconds; + _greetT += _dt / secs; + if (_greetT >= 1f) { _greetT = 0f; _greet = GreetingKind.None; } + } + } + + if (_askTyped != null || _asks.Pending != null || _greet != GreetingKind.None + || !_settings.Enabled(Halo.Settings.FeatureId.Notifications)) + { while (_notifSrc.Dequeue() is not null) { } } + else if (_notif == null && !_notifClosing && _progress <= 0.02f && _drop < 0f + && _notifSrc.Dequeue() is { } item) + { + _notif = item; + _notifDetailOn = false; + _notifDetail = 0f; + _notifDetailH = NotifBanner.DetailHeight(item); + _notifDeadline = DateTime.UtcNow.AddSeconds(item.Duration); + } + + if (_notif != null && _asks.Pending != null && !_notifDetailOn) _notifClosing = true; + float prevAskT = _askT; + int prevAskHover = _askHover; + var pendingAsk = _notif == null && _settings.Current.Bool("claude.ask", true) ? _asks.Pending : null; + if (pendingAsk?.Nonce != _ask?.Nonce) + { + EndTyping(); + _ask = pendingAsk; + _askHover = -1; + + if (_ask != null && _askDraftNonce == _ask.Nonce && _askDraft.Length > 0) BeginTyping(); + } + + if (_ask != null) + { + _askChips = AskBanner.Chips(_ask, AskBanner.W); + _askH = AskBanner.Height(_ask, AskBanner.W); + + LayeredNotch.WantCaptureHeight(_askH); + } + else if (_notif == null) LayeredNotch.WantCaptureHeight(0); + _askT = Math.Clamp(_askT + (_ask != null ? _dt / 0.24f : -_dt / 0.30f), 0f, 1f); + if (_ask != null) + { + _askHover = -1; + if (InRect(p, NotifLeft(), _ct, Sc(_curW), Sc(_curH))) + for (int i = 0; i < _askChips.Count; i++) + if (InChip(p, _askChips[i].Rect)) { _askHover = i; break; } + } + + float prevNotifT = _notifT, prevNotifDetail = _notifDetail; + bool overNotif = false; + if (_notif != null) + { + overNotif = InRect(p, NotifLeft(), _ct, Sc(_curW), Sc(_curH)); + if (overNotif && !_notifDetailOn && _notif.Kind != "language") + _notifDeadline = Max(_notifDeadline, DateTime.UtcNow.AddSeconds(2.5)); + if (!_notifDetailOn && DateTime.UtcNow > _notifDeadline) _notifClosing = true; + _notifT = Math.Clamp(_notifT + (_notifClosing ? -_dt / 0.30f : _dt / 0.24f), 0f, 1f); + _notifDetail = Math.Clamp(_notifDetail + (_notifDetailOn ? 1 : -1) * _dt / 0.22f, 0f, 1f); + if (_notifClosing && _notifT <= 0f) + { + _notif = null; + _notifClosing = false; + _notifDetailOn = false; + _notifDetail = 0f; + } + } + + bool down = (Win32.GetAsyncKeyState(Win32.VK_LBUTTON) & 0x8000) != 0; + bool inHandle = _progress > 0.9f + && p.X >= _el + Sc(ExpandedW - 44) && p.X < _el + Sc(ExpandedW) + 8 + && p.Y >= _et + Sc(ExpandedH - 44) && p.Y < _et + Sc(ExpandedH) + 8; + bool rescaled = false; + if (_resizing) + { + if (down) + { + float ns = Math.Clamp(_scale0 + + ((p.X - _resizeFrom.X) + (p.Y - _resizeFrom.Y)) / (float)(ExpandedW + ExpandedH), + 0.7f, 1.6f); + rescaled = ns != _notch.Scale; + _notch.Scale = ns; + } + else { _resizing = false; _notch.SaveScale(); } + } + else if (down && !_lastMouseDown && inHandle && !_moving) + { + _resizing = true; + _resizeFrom = p; + _scale0 = _notch.Scale; + } + float prevHandle = _handle; + _handle = Math.Clamp(_handle + (inHandle || _resizing ? 1 : -1) * _dt / 0.12f, 0f, 1f); + _notch.HandleAlpha = _handle; + + bool hovered = _resizing || _moving || (_progress > 0.02f + ? InRect(p, _el, _et, Sc(ExpandedW), Sc(ExpandedH)) + : InRect(p, _cl, _ct, Sc(CollapsedW), Sc(CollapsedH))); + float prevOffsetX = _offsetX, prevHoldT = _holdT; + UpdateMove(p, down, hovered); + + bool open = (hovered || notice || FileTray.DragActive || _apiHold) + && !_empty && _notif == null && !_moving; + + int dir = open ? 1 : -1; + + float step = _dt / ((open ? OpenSeconds : CloseSeconds) * MotionScale); + + float next = open && FileTray.DragActive ? 1f : Math.Clamp(_progress + dir * step, 0f, 1f); + + int alt = AltIndices().Length; + bool inMenu = _progress < 0.05f && _drop < 0f && InMenu(p); + float mnext = alt >= 2 && inMenu ? Math.Min(_menu + step, 1f) : Math.Max(_menu - step, 0f); + + var rows = Groups(); + int hoverRow = -1; + if (inMenu && p.Y >= _ct) + { + int r0 = (p.Y - _ct) / Sc(LayeredNotch.CircleD); + if (r0 >= 0 && r0 < rows.Count) hoverRow = r0; + } + if (hoverRow != _row && hoverRow >= 0) { _row = hoverRow; _rowOpen = 0f; } + float rnext = _row >= 0 && _row < rows.Count && rows[_row].Length >= 2 && inMenu && hoverRow == _row + ? Math.Min(_rowOpen + step, 1f) + : Math.Max(_rowOpen - step, 0f); + if (mnext <= 0f && rnext <= 0f) _row = -1; + + float dnext = _drop; + if (_drop >= 0f) + { + dnext = _drop + _dt / 0.34f; + if (dnext >= 1f) + { + if (!_dropOut) { _primary = _pending; _agentNotices.SetPrimary(_primary); _arrive = 0f; _userPicked = _pending; } + _dropOut = false; + dnext = -1f; + } + } + + float anext = _arrive; + if (_arrive >= 0f) { anext = _arrive + _dt / 0.22f; if (anext >= 1f) anext = -1f; } + + float prevMenu = _menu, prevDrop = _drop, prevArrive = _arrive, prevRowOpen = _rowOpen; + _menu = mnext; + _rowOpen = rnext; + _drop = dnext; + _arrive = anext; + PollClick(p); + HandleTrayInteraction(p, down); + + bool startExpand = _progress <= 0.02f && next > 0.02f; + bool deskChanged = false; + if (_askTyped == null && (fg != _lastFg || startExpand)) + { + + bool follow = _settings.Current.Bool(Halo.Settings.SettingsKeys.FollowFocus, true); + if (follow && fg != _lastFg && _drop < 0f && !_agentNotices.IsOpen(now)) + FollowForeground(fg); + if (follow && fg != _lastFg) FollowForegroundMedia(ProcessNameOf(fg)); + _lastFg = fg; + bool desk = _notch.ProbeBehind(out _behind); + deskChanged = desk != _lastDesktop; + _lastDesktop = desk; + if (deskChanged && !desk) _lastCaptureAt = 0; + } + + int captureEveryMs = _progress > 0.5f ? CaptureOpenMs : CaptureCollapsedMs; + if (_heavy) captureEveryMs *= 3; + + if (_progress <= 0.5f) captureEveryMs *= Math.Clamp(1 + _notch.StaleStreak / 6, 1, 4); + if (!_lastDesktop && _behind != IntPtr.Zero && frameNow - _lastCaptureAt >= captureEveryMs) + { + _lastCaptureAt = frameNow; + _notch.CaptureFrom(_behind); + } + int cv = _notch.CaptureVersion; + bool refreshed = cv != _lastCaptureVer; + _lastCaptureVer = cv; + + bool tick = DateTime.Now.Second != _lastSec; + _lastSec = DateTime.Now.Second; + + bool forceAnim = false; + bool animating = _widgets[_primary].Animating; + if (animating && _progress >= 0.5f) forceAnim = true; + else if (animating && ++_animTick >= 4) { _animTick = 0; forceAnim = true; } + + bool overNow = _notif != null ? overNotif : hovered && next > 0.98f; + var mouse = _notif != null + ? new PointF((p.X - NotifLeft()) / S, (p.Y - _ct) / S) + : new PointF((p.X - _el) / S, (p.Y - _et) / S); + bool mouseMoved = WidgetInput.Over != overNow || (overNow && WidgetInput.Mouse != mouse); + WidgetInput.Over = overNow; + WidgetInput.Mouse = mouse; + + WidgetInput.Down = (Win32.GetAsyncKeyState(Win32.VK_LBUTTON) & 0x8000) != 0; + + float prevStrip = _stripT; + _stripT = Math.Clamp(_stripT + (AltIndices().Length >= 1 ? 1 : -1) * _dt / 0.22f, 0f, 1f); + + float prevShrink = _shrink; + _shrink = Math.Clamp(_shrink + (_empty ? 1 : -1) * _dt / 0.28f, 0f, 1f); + + int wv = WidgetVersion(); + bool changed = next != _progress || wv != _widgetVersion || deskChanged || wasEmpty != _empty + || refreshed || tick || _menu != prevMenu || _drop != prevDrop || _arrive != prevArrive + || _rowOpen != prevRowOpen || forceAnim || mouseMoved || rescaled || _handle != prevHandle + || _shrink != prevShrink || _stripT != prevStrip || _notifT != prevNotifT || _notifDetail != prevNotifDetail + || _offsetX != prevOffsetX || _holdT != prevHoldT || !ReferenceEquals(_notif, notifStart) + || _askT != prevAskT || _askHover != prevAskHover || _askTyped != _drawnTyped + || _greetT != prevGreetT || _greet != prevGreet + + || _carryDY != _drawnCarryDY || _dragRow != _drawnDragRow + + || _dragHeld >= DragHold; + _progress = next; + _widgetVersion = wv; + + _drawnTyped = _askTyped; + _drawnCarryDY = _carryDY; + _drawnDragRow = _dragRow; + if (changed) Apply(_progress); + } + + private bool InChip(Win32.POINT p, RectangleF r) + => p.X >= NotifLeft() + r.X * S && p.X < NotifLeft() + r.Right * S + && p.Y >= _ct + r.Y * S && p.Y < _ct + r.Bottom * S; + + private bool InMenu(Win32.POINT p) + { + var rows = Groups(); + if (rows.Count == 0) return false; + int D = Sc(LayeredNotch.CircleD); + int x = _cl + Sc(CollapsedW + LayeredNotch.CircleGap + LayeredNotch.PrivacyPad); + float openV = EaseOutBack(Math.Clamp(_menu, 0f, 1f)); + float hNow = D + (rows.Count - 1) * D * Math.Max(0f, openV); + if (p.X >= x && p.X < x + D && p.Y >= _ct && p.Y < _ct + Math.Max(D, hNow)) + return true; + if (_row >= 0 && _row < rows.Count && _rowOpen > 0f) + { + float ext = rows[_row].Length * D * EaseOutBack(Math.Clamp(_rowOpen, 0f, 1f)); + if (p.X >= x + D && p.X < x + D + ext + && p.Y >= _ct + _row * D && p.Y < _ct + (_row + 1) * D) + return true; + } + return false; + } + + private readonly Dictionary _ringShown = new(); + private void EaseRings() + { + for (int i = 0; i < _widgets.Length; i++) + { + if (_widgets[i].Ring is not { } target) { _ringShown.Remove(i); continue; } + if (!_ringShown.TryGetValue(i, out var shown)) { _ringShown[i] = target; continue; } + float k = 1f - MathF.Exp(-_dt / 0.22f); + _ringShown[i] = Color.FromArgb( + (int)MathF.Round(shown.A + (target.A - shown.A) * k), + (int)MathF.Round(shown.R + (target.R - shown.R) * k), + (int)MathF.Round(shown.G + (target.G - shown.G) * k), + (int)MathF.Round(shown.B + (target.B - shown.B) * k)); + } + } + + private Color? RingOf(int i) + => _widgets[i].Ring is { } target ? (_ringShown.TryGetValue(i, out var c) ? c : target) : null; + + private Color? GroupRing(int[] gr) + { + Color? first = null; + foreach (var i in gr) + { + if (RingOf(i) is not { } rc) continue; + first ??= rc; + if (rc.R != rc.G || rc.G != rc.B) return rc; + } + return first; + } + + private List Groups() + { + var byKind = new Dictionary>(); + var order = new List(); + foreach (var i in AltIndices()) + { + string kind = _widgets[i] switch + { + MediaWidget => "media", + VlcWidget => "vlc", + DownloadWidget => "download", + FileTray => "filetray", + ClaudeCodeWidget => "claude", + CodexWidget => "codex", + GenericAgentWidget ga => "g:" + ga.GroupKey, + _ => "other", + }; + if (!byKind.TryGetValue(kind, out var list)) { list = new List(); byKind[kind] = list; order.Add(kind); } + list.Add(i); + } + + _stripKinds = _stripOrder.Apply(order); + return _stripKinds.ConvertAll(k => byKind[k].ToArray()); + } + + private readonly Halo.Settings.SettingsStore _settings; + + private bool Live(int i) + { + var feature = FeatureOf(_widgets[i]); + return _widgets[i].IsActive && (feature is null || _settings.Enabled(feature.Value)); + } + + private static Halo.Settings.FeatureId? FeatureOf(IWidget widget) => widget switch + { + MediaWidget or VlcWidget => Halo.Settings.FeatureId.Media, + DownloadWidget => Halo.Settings.FeatureId.Downloads, + FileTray => Halo.Settings.FeatureId.FileTray, + Widgets.BtWidget => Halo.Settings.FeatureId.Bluetooth, + ClaudeCodeWidget => Halo.Settings.FeatureId.ClaudeCode, + CodexWidget => Halo.Settings.FeatureId.Codex, + GenericAgentWidget => Halo.Settings.FeatureId.GenericAgents, + _ => null, + }; + + private int[] ActiveIndices() + { + var active = new List(_widgets.Length); + for (int i = 0; i < _widgets.Length; i++) + if (Live(i)) + active.Add(i); + return [.. active]; + } + + private int[] AltIndices() + { + var act = ActiveIndices(); + int n = 0; + foreach (var i in act) if (i != _primary) n++; + var r = new int[n]; + int j = 0; + foreach (var i in act) if (i != _primary) r[j++] = i; + return r; + } + + private int WidgetVersion() + { + int v = Privacy.Version; + v += _settings.Version; + foreach (var wgt in _widgets) v += wgt.Version; + return v; + } + + private const float DragHold = 0.26f; + + private bool UpdateStripGesture(Win32.POINT p, bool down) + { + bool live = _progress < 0.1f && ActiveIndices().Length >= 2 && _drop < 0f && _notif == null + && _ask == null && _greet == GreetingKind.None; + int D = Sc(LayeredNotch.CircleD); + + if (down && !_lastMouseDown) + { + if (!live || !InMenu(p)) return false; + _dragRow = Math.Clamp((p.Y - _ct) / D, 0, Math.Max(0, Groups().Count - 1)); + _dragFromY = p.Y; + _dragHeld = 0f; + return true; + } + if (_dragRow < 0) return false; + if (!live) { _dragRow = -1; return false; } + + if (down) + { + _dragHeld += _dt; + if (_dragHeld < DragHold) { _carryDY = 0f; return true; } + + _carryWant = (p.Y - _dragFromY) / S; + _carryDY = Lerp(_carryDY, _carryWant, Math.Clamp(_dt / 0.045f, 0f, 1f)); + + int steps = (int)((p.Y - _dragFromY) / D); + if (steps != 0 && _dragRow < _stripKinds.Count) + { + string kind = _stripKinds[_dragRow]; + if (_stripOrder.Move(_stripKinds, kind, steps)) + { + _stripOrder.Save(StripOrderPath); + _dragRow = Math.Clamp(_dragRow + steps, 0, _stripKinds.Count - 1); + _dragFromY += steps * D; + + _carryWant = (p.Y - _dragFromY) / S; + _carryDY -= steps * LayeredNotch.CircleD; + } + } + return true; + } + + bool wasTap = _dragHeld < DragHold && Math.Abs(p.Y - _dragFromY) < D / 2; + int row = _dragRow; + _dragRow = -1; + _dragHeld = 0f; + _carryDY = 0f; + if (wasTap && InMenu(p)) JumpToRow(p, row, D); + return true; + } + + private void JumpToRow(Win32.POINT p, int row, int D) + { + var rows = Groups(); + if (rows.Count == 0) return; + row = Math.Clamp(row, 0, rows.Count - 1); + int mx = _cl + Sc(CollapsedW + LayeredNotch.CircleGap + LayeredNotch.PrivacyPad); + var grp = rows[row]; + int rel = (p.X - mx) / D; + int pick = rel <= 0 || grp.Length == 1 ? 0 : Math.Clamp(rel - 1, 0, grp.Length - 1); + _pending = grp[pick]; + _dropIcon = _widgets[_pending].Icon; + _dropImage = _widgets[_pending].IconImage; + int DL = LayeredNotch.CircleD; + _dropCX = rel <= 0 ? DL / 2f : (rel + 0.5f) * DL; + _dropCY = (row + 0.5f) * DL; + _drop = 0f; + _menu = 0f; + _rowOpen = 0f; + _row = -1; + } + + private void PollClick(Win32.POINT p) + { + bool down = (Win32.GetAsyncKeyState(Win32.VK_LBUTTON) & 0x8000) != 0; + if (_moving) { _lastMouseDown = down; return; } + if (UpdatePinGesture(p, down)) { _lastMouseDown = down; return; } + if (UpdateStripGesture(p, down)) { _lastMouseDown = down; return; } + + if (down && !_lastMouseDown && !_resizing && _notif == null && _ask is { } ask && _askT > 0.5f) + { + bool hitRow = false; + for (int i = 0; i < _askChips.Count; i++) + if (InChip(p, _askChips[i].Rect)) + { + hitRow = true; + + if (AskBanner.IsOther(_askChips[i].Option)) BeginTyping(); + + else if (_asks.Answer(ask, _askChips[i].Option.Label)) + { + EndTyping(); + ClearDraft(); + _ask = null; + _askHover = -1; + } + break; + } + + if (!hitRow && _askTyped != null && !InRect(p, NotifLeft(), _ct, Sc(_curW), Sc(_curH))) + EndTyping(); + _lastMouseDown = down; + return; + } + if (down && !_lastMouseDown && !_resizing && _notif != null) + { + + var copyR = NotifBanner.CopyRect(_notif, _curW); + if (!InRect(p, NotifLeft(), _ct, Sc(_curW), Sc(_curH))) + _notifClosing = true; + + else if (!copyR.IsEmpty + && p.X >= NotifLeft() + copyR.X * S && p.X < NotifLeft() + copyR.Right * S + && p.Y >= _ct + copyR.Y * S && p.Y < _ct + copyR.Bottom * S) + { + Halo.Interop.Clipboard.SetText(_notif.Code); + _notif.Copied = true; + _notifDeadline = Max(_notifDeadline, DateTime.UtcNow.AddSeconds(2)); + } + + else if (!_notifDetailOn && NotifBanner.BodyOverflows(_notif) && p.Y >= _ct + Sc(_curH - 22)) + { + _notifDetailOn = true; + _notifDeadline = DateTime.MaxValue; + } + else + { + _notif.Activate(); + _notifClosing = true; + } + } + else if (down && !_lastMouseDown && !_resizing) + { + if (_progress > 0.9f) + { + + foreach (var (r, onClick) in _widgets[_primary].Buttons(ExpandedW, ExpandedH)) + { + float bx = _el + r.X * S, by = _et + r.Y * S; + if (p.X >= bx && p.X < bx + r.Width * S && p.Y >= by && p.Y < by + r.Height * S) + { + onClick(new PointF((p.X - _el) / S, (p.Y - _et) / S)); + break; + } + } + } + else if (_progress < 0.1f && TryCollapsedButton(p)) { } + + } + _lastMouseDown = down; + } + + private void HandleTrayInteraction(Win32.POINT p, bool down) + { + if (!(_progress > 0.9f && _drop < 0f && !_moving && _notif == null && _widgets[_primary] is FileTray tray)) + { + if (_trayMode == 1) FileTray.CancelReorder(); + _trayPressPath = null; _trayMode = -1; _lastTrayDown = down; return; + } + + var local = new PointF((p.X - _el) / S, (p.Y - _et) / S); + bool inside = InRect(p, _el, _et, Sc(ExpandedW), Sc(ExpandedH)); + bool ctrl = (Win32.GetAsyncKeyState(Win32.VK_CONTROL) & 0x8000) != 0; + + if (down && !_lastTrayDown) + { + _trayPressPath = tray.RowPathAt(ExpandedW, ExpandedH, local); + _trayPressAt = p; + _trayMode = 0; + if (_trayPressPath != null && ctrl) { FileTray.ToggleSelect(_trayPressPath); _trayPressPath = null; _trayMode = -1; } + } + else if (down && _trayMode == 0 && _trayPressPath != null) + { + int dx = p.X - _trayPressAt.X, dy = p.Y - _trayPressAt.Y; + if (!inside) StartTrayDragOut(); + else if (dx * dx + dy * dy > 36) + { + _trayMode = 1; + FileTray.BeginReorder(_trayPressPath); + FileTray.UpdateReorder(tray.RowIndexAt(ExpandedW, ExpandedH, local)); + } + } + else if (down && _trayMode == 1) + { + if (!inside) { FileTray.CancelReorder(); StartTrayDragOut(); } + else FileTray.UpdateReorder(tray.RowIndexAt(ExpandedW, ExpandedH, local)); + } + + if (!down && _lastTrayDown) + { + if (_trayMode == 1) FileTray.CommitReorder(); + else if (_trayMode == 0 && _trayPressPath != null) { FileTray.ClearSelection(); FileTray.Open(_trayPressPath); } + _trayPressPath = null; _trayMode = -1; + } + _lastTrayDown = down; + } + + private void StartTrayDragOut() + { + var paths = _trayPressPath != null ? FileTray.SelectionOrRow(_trayPressPath) : Array.Empty(); + _trayMode = 2; + _trayPressPath = null; + + if (paths.Length > 0 && Halo.Interop.FileDrag.Out(paths) && !CursorOverNotch()) FileTray.RemovePaths(paths); + _trayPressPath = null; _trayMode = -1; + } + + private bool CursorOverNotch() + { + return Win32.GetCursorPos(out var p) && Win32.GetWindowRect(_notch.Hwnd, out var r) + && p.X >= r.left && p.X < r.right && p.Y >= r.top && p.Y < r.bottom; + } + + private static bool InRect(Win32.POINT p, int left, int top, int w, int h) + => p.X >= left && p.X < left + w && p.Y >= top && p.Y < top + h; + + private bool OverPressable(Point p) + { + try + { + if (_empty || _primary < 0 || _primary >= _widgets.Length) return false; + if (_progress > 0.9f) + { + if (Contains(PinRect(ExpandedW, ExpandedH), _el, _et, p)) return true; + foreach (var (r, _) in _widgets[_primary].Buttons(ExpandedW, ExpandedH)) + if (Contains(r, _el, _et, p)) return true; + return false; + } + if (_progress < 0.1f) + foreach (var (r, _) in _widgets[_primary].CollapsedButtons(CollapsedW, CollapsedH)) + if (Contains(r, _cl, _ct, p)) return true; + return false; + } + catch { return false; } + } + + private bool Contains(RectangleF r, int left, int top, Point p) + { + float bx = left + r.X * S, by = top + r.Y * S; + return p.X >= bx && p.X < bx + r.Width * S && p.Y >= by && p.Y < by + r.Height * S; + } + + private bool TryCollapsedButton(Win32.POINT p) + { + if (_primary < 0 || _primary >= _widgets.Length || _empty) return false; + try + { + foreach (var (r, onClick) in _widgets[_primary].CollapsedButtons(CollapsedW, CollapsedH)) + { + float bx = _cl + r.X * S, by = _ct + r.Y * S; + if (p.X >= bx && p.X < bx + r.Width * S && p.Y >= by && p.Y < by + r.Height * S) + { + onClick(new PointF((p.X - _cl) / S, (p.Y - _ct) / S)); + return true; + } + } + } + catch { } + return false; + } + + internal static Bitmap? MenuRowImage(IWidget[] widgets, int[] group) + { + if (group.Length == 0) return null; + if (group.Length < 2) return widgets[group[0]].IconImage; + return widgets[group[0]] switch + { + ClaudeCodeWidget => ClaudeCodeWidget.PlainIcon, + CodexWidget => CodexWidget.PlainIcon, + _ => widgets[group[0]].IconImage, + }; + } + + internal static float MenuRowImageOffset(IWidget[] widgets, int[] group) + => group.Length == 0 ? 0f : widgets[group[0]].IconOffsetX; + + private void Apply(float t) + { + float e = EaseOutBack(t); + int w = (int)Lerp(CollapsedW, ExpandedW, e); + int h = (int)Lerp(CollapsedH, ExpandedH, e); + int r = (int)Lerp(CollapsedR, ExpandedR, e); + if (_shrink > 0f) + { + float s = SmoothStep(_shrink); + w = (int)Lerp(w, 96, s); + h = (int)Lerp(h, 12, s); + r = (int)Lerp(r, 6, s); + } + bool glass = !_lastDesktop; + + int cT = (int)((glass ? TintAppCollapsed : TintDeskCollapsed) * GlassScale); + int eT = (int)((glass ? TintAppExpanded : TintDeskExpanded) * GlassScale); + int tint = (int)Lerp(cT, eT, t); + + if (_empty && !Privacy.Active) + tint = (int)Lerp(tint, EmptyCatchAlpha, SmoothStep(_shrink)); + float fade = Math.Clamp((t - 0.45f) / 0.55f, 0f, 1f); + float mini = Math.Clamp(1f - t / 0.35f, 0f, 1f); + + if (_notif == null && _ask != null && _askT > 0f) + { + float ea = EaseOutBack(_askT); + w = (int)Lerp(w, AskBanner.W, ea); + h = (int)Lerp(h, _askH, ea); + r = (int)Lerp(r, 26, ea); + tint = (int)Lerp(cT, glass ? TintAskApp : TintAskDesk, _askT); + fade = Math.Clamp((_askT - 0.45f) / 0.55f, 0f, 1f); + + mini *= Math.Clamp(1f - _askT / 0.35f, 0f, 1f); + } + if (_notif != null && _notifT > 0f) + { + float en = EaseOutBack(_notifT); + float nh = Lerp(NotifBanner.SummaryH, _notifDetailH, SmoothStep(_notifDetail)); + w = (int)Lerp(w, NotifBanner.W, en); + h = (int)Lerp(h, nh, en); + r = (int)Lerp(r, 26, en); + tint = (int)Lerp(cT, eT, _notifT); + fade = Math.Clamp((_notifT - 0.45f) / 0.55f, 0f, 1f); + mini *= Math.Clamp(1f - _notifT / 0.35f, 0f, 1f); + } + float arrive = _arrive < 0f ? 1f : 1f - (1f - _arrive) * (1f - _arrive); + mini *= arrive; + + var groups = _empty ? new List() : Groups(); + + if (_rowShift.Length != groups.Count) _rowShift = new float[groups.Count]; + bool carrying = _dragHeld >= DragHold && _dragRow >= 0 && _dragRow < groups.Count; + float at = carrying ? _dragRow + _carryDY / LayeredNotch.CircleD : 0f; + for (int i = 0; i < _rowShift.Length; i++) + { + float target = 0f; + if (carrying && i != _dragRow) + { + if (_dragRow < i && at >= i) target = -LayeredNotch.CircleD; + else if (_dragRow > i && at <= i) target = LayeredNotch.CircleD; + } + _rowShift[i] = Lerp(_rowShift[i], target, Math.Clamp(_dt / 0.11f, 0f, 1f)); + } + var frame = new MenuFrame + { + CarryRow = _dragHeld >= DragHold ? _dragRow : -1, + CarryDY = _carryDY, + RowShift = _rowShift, + Show = _greet == GreetingKind.None && (groups.Count >= 1 || _stripT > 0.01f), + Appear = SmoothStep(_stripT), + + RowIcons = groups.ConvertAll(gr => _widgets[gr[0]].Icon).ToArray(), + RowImages = groups.ConvertAll(gr => MenuRowImage(_widgets, gr)).ToArray(), + RowImageOffsets = groups.ConvertAll(gr => MenuRowImageOffset(_widgets, gr)).ToArray(), + RowCounts = groups.ConvertAll(gr => gr.Length >= 2 ? gr.Length : 0).ToArray(), + SessIcons = groups.ConvertAll(gr => gr.Length >= 2 + ? Array.ConvertAll(gr, i => _widgets[i].Icon) : Array.Empty()).ToArray(), + SessImages = groups.ConvertAll(gr => gr.Length >= 2 + ? Array.ConvertAll(gr, i => _widgets[i].IconImage) : Array.Empty()).ToArray(), + RowRings = groups.ConvertAll(GroupRing).ToArray(), + RowProgress = groups.ConvertAll(gr => _widgets[gr[0]].RingProgress).ToArray(), + + SessRings = groups.ConvertAll(gr => gr.Length >= 2 + ? gr.Select((i, j) => (Color?)(RingOf(i) is { } rc ? Fx.Shade(rc, j) : null)).ToArray() + : Array.Empty()).ToArray(), + Open = EaseOutBack(Math.Clamp(_menu, 0f, 1f)), + OpenRow = _row, + RowOpen = EaseOutBack(Math.Clamp(_rowOpen, 0f, 1f)), + Dropping = _drop >= 0f, + DropIcon = _dropIcon, + DropImage = _dropImage, + Drop = _drop >= 0f ? _drop : 0f, + }; + frame.Outward = _dropOut; + if (frame.Dropping) + { + float circleX = w + LayeredNotch.CircleGap + LayeredNotch.PrivacyPad + _dropCX; + float circleY = LayeredNotch.CircleY + _dropCY; + float pillX = w - h / 2f, pillY = h / 2f; + (frame.FromX, frame.FromY, frame.ToX, frame.ToY) = _dropOut + ? (pillX, pillY, circleX, circleY) + : (circleX, circleY, pillX, pillY); + } + + if (_greet != GreetingKind.None) + { + var gf = _greet == GreetingKind.Install + ? GreetingPlan.Install(_greetT) : GreetingPlan.Login(_greetT); + w = (int)gf.PillW; + h = (int)gf.PillH; + r = (int)gf.Radius; + fade = 1f; + mini = 0f; + + _drop = -1f; + _arrive = -1f; + _stripT = 0f; + } + + Action content = _greet != GreetingKind.None + ? (g, cw, ch, f) => DrawGreeting(g, cw, ch) + : _notif == null && _ask is { } q && _askT > 0f + ? (g, cw, ch, f) => AskBanner.Draw(g, cw, ch, f, q, _askHover, tint, _askTyped) + : _notif is { } toast && _notifT > 0f + ? (g, cw, ch, f) => NotifBanner.Draw(g, cw, ch, f, toast, SmoothStep(_notifDetail), _notifDetailOn) + : _empty ? static (_, _, _, _) => { } : _widgets[_primary].DrawContent; + + bool pin = _notif == null && _ask == null && _greet == GreetingKind.None && !TrayFront; + _curW = w; + _curH = h; + _notch.OffsetX = _offsetX; + float holdCue = _moving ? 0f : _holdT; + + bool banner = _notif != null || (_ask != null && _askT > 0f); + float glassFade = _empty && !Privacy.Active && !banner ? 1f - SmoothStep(_shrink) : 1f; + _notch.Render(w, h, r, tint, fade, mini, glass, frame, + (g, cw, ch, f) => { content(g, cw, ch, f); if (pin) DrawPin(g, cw, ch, f); if (holdCue > 0.01f) DrawHoldCue(g, cw, ch); }, + _empty ? static (_, _, _, _) => { } : _widgets[_primary].DrawCollapsed, + glassFade, banner ? BannerClarity : 0f); + } + + private void DrawGreeting(Graphics g, int w, int h) + { + var f = _greet == GreetingKind.Install + ? GreetingPlan.Install(_greetT) : GreetingPlan.Login(_greetT); + var box = Greeting.InkBox(w, h); + + Greeting.DrawHello(g, box, f.Written, f.HelloAlpha, Color.White, + _greet == GreetingKind.Install ? 9f : 11f); + if (f.LineAlpha > 0.004f) + Greeting.DrawLine(g, Greeting.Lines[f.LineIndex], box, f.LineWritten, f.LineAlpha, Color.White, + _greet == GreetingKind.Install ? 9f : 11f); + } + + private void BeginTyping() + { + if (_askTyped != null) return; + _askTyped = _askDraftNonce == _ask?.Nonce ? _askDraft : ""; + _keys.Start(); + } + + private void EndTyping() + { + if (_askTyped == null && !_keys.Active) return; + if (_askTyped != null) { _askDraft = _askTyped; _askDraftNonce = _ask?.Nonce; } + _askTyped = null; + _keys.Stop(); + } + + private void ClearDraft() + { + _askDraft = ""; + _askDraftNonce = null; + } + + private void TypedChar(char c) + { + if (_askTyped == null) return; + if (c < ' ' || c == 0x7F) return; + if (_askTyped.Length >= 400) return; + _askTyped += c; + } + + private void TypedKey(int vk) + { + if (_askTyped == null) return; + if (vk == Win32.VK_BACK) + { + if (_askTyped.Length > 0) _askTyped = _askTyped[..^1]; + } + else if (vk == Win32.VK_ESCAPE) EndTyping(); + else if (vk == Win32.VK_RETURN) + { + string answer = _askTyped.Trim(); + + if (answer.Length > 0 && _ask is { } ask) + { + _asks.Answer(ask, answer); + _ask = null; + _askHover = -1; + EndTyping(); + ClearDraft(); + return; + } + EndTyping(); + } + else if (vk == Win32.VK_V) + { + + try + { + if (Clipboard.Text() is { Length: > 0 } t) + _askTyped = (_askTyped + t.Replace('\r', ' ').Replace('\n', ' ')).Trim(); + } + catch { } + } + } + + private void FollowForeground(IntPtr fg) + { + try + { + Win32.GetWindowThreadProcessId(fg, out uint pid); + if (pid == 0) return; + for (int i = 0; i < _widgets.Length; i++) + { + if (i == _primary || !Live(i)) continue; + foreach (var owner in _widgets[i].OwnerPids) + if (owner == (int)pid) + { + _primary = i; + _agentNotices.SetPrimary(i); + return; + } + } + } + catch { } + } + + private void FollowForegroundMedia(string fgProc) + { + if (string.IsNullOrEmpty(fgProc)) return; + if (_widgets[_primary] is not MediaWidget pm || !pm.IsActive || !AppMatches(pm.App, fgProc)) return; + for (int i = 0; i < _widgets.Length; i++) + if (i != _primary && _widgets[i] is MediaWidget m && m.IsActive) + { _primary = i; _agentNotices.SetPrimary(i); return; } + } + + private static bool AppMatches(string app, string proc) + { + proc = proc.ToLowerInvariant(); + return app.Length > 1 && proc.Length > 1 && (app == proc || app.Contains(proc) || proc.Contains(app)); + } + + private Dictionary _parentMap = new(); + private long _parentMapAt; + private Dictionary ParentMap() + { + long now = Environment.TickCount64; + if (_parentMap.Count > 0 && now - _parentMapAt < 2000) return _parentMap; + var snap = Win32.CreateToolhelp32Snapshot(Win32.TH32CS_SNAPPROCESS, 0); + if (snap == new IntPtr(-1)) return _parentMap; + try + { + var map = new Dictionary(512); + var pe = new Win32.PROCESSENTRY32W + { dwSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() }; + if (Win32.Process32FirstW(snap, ref pe)) + do { map[(int)pe.th32ProcessID] = (int)pe.th32ParentProcessID; } + while (Win32.Process32NextW(snap, ref pe)); + if (map.Count > 0) { _parentMap = map; _parentMapAt = now; } + } + finally { Win32.CloseHandle(snap); } + return _parentMap; + } + + private bool FgHostsWidget(int fgPid, int widget) + { + if (fgPid <= 4) return false; + var map = ParentMap(); + foreach (var owner in _widgets[widget].OwnerPids) + { + int p = owner, guard = 0; + while (p > 4 && guard++ < 32) + { + if (p == fgPid) return true; + if (!map.TryGetValue(p, out p)) break; + } + } + return false; + } + + private static RectangleF PinRect(int w, int h) => new(9, 4, 24, 24); + + private bool OverPin(Win32.POINT p) + { + var r = PinRect(ExpandedW, ExpandedH); + return p.X >= _el + r.X * S && p.X < _el + (r.X + r.Width) * S + && p.Y >= _et + r.Y * S && p.Y < _et + (r.Y + r.Height) * S; + } + + private DateTime _pinPressAt = DateTime.MaxValue; + private bool _pinHoldFired; + private const double PinHoldSeconds = 0.55; + + private bool UpdatePinGesture(Win32.POINT p, bool down) + { + + bool over = _progress > 0.9f && _notif == null && !TrayFront && OverPin(p); + if (down && !_lastMouseDown) + { + if (!over) return false; + _pinPressAt = DateTime.UtcNow; + _pinHoldFired = false; + return true; + } + if (_pinPressAt == DateTime.MaxValue) return false; + + if (down) + { + if (!_pinHoldFired && (DateTime.UtcNow - _pinPressAt).TotalSeconds >= PinHoldSeconds) + { + _pinHoldFired = true; + _recordable = !_recordable; + SaveRecordable(); + _notch.SetCapturable(_recordable); + } + return true; + } + + if (!_pinHoldFired && over) { _pinned = !_pinned; SavePin(); } + _pinPressAt = DateTime.MaxValue; + return true; + } + + private float PinHoldProgress() + => _pinPressAt == DateTime.MaxValue || _pinHoldFired ? 0f + : Math.Clamp((float)((DateTime.UtcNow - _pinPressAt).TotalSeconds / PinHoldSeconds), 0f, 1f); + + private void DrawPin(Graphics g, int w, int h, float a) + { + if (a <= 0.01f) return; + var r = PinRect(w, h); + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + _pinHov = Toward(_pinHov, hov ? 1f : 0f, _dt / 0.10f); + float hv = _pinHov * _pinHov * (3f - 2f * _pinHov); + DrawPushpin(g, r, _pinned, hv, a, _recordable, PinHoldProgress()); + if (hv > 0.02f) + { + using var f = new Font("Segoe UI", 11f, GraphicsUnit.Pixel); + + string label = _pinned ? "unpin" : "pin on top"; + + var sz = g.MeasureString(label, f); + var chip = new RectangleF(r.Right + 6, r.Y + (r.Height - 17) / 2f, sz.Width + 12, 17); + using (var bgb = new SolidBrush(Color.FromArgb((int)(215 * hv * a), 18, 18, 20))) + using (var chipPath = Fx.Rounded(chip, 6f)) + g.FillPath(bgb, chipPath); + using var b = new SolidBrush(Color.FromArgb((int)(230 * hv * a), 235, 235, 235)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { LineAlignment = StringAlignment.Center, Alignment = StringAlignment.Center }; + g.DrawString(label, f, b, chip, sf); + } + } + + private static readonly Color Slate = Color.FromArgb(255, 154, 165, 180); + + private static void Sphere(Graphics g, RectangleF head, float hr, GraphicsPath? needle, float a, + Color baseColor, Color? needleColor = null) + { + int A(float f) => (int)Math.Clamp(f * a, 0, 255); + Color Tint(Color c, float k) => Color.FromArgb(A(255), + (int)Math.Clamp(c.R * k, 0, 255), + (int)Math.Clamp(c.G * k, 0, 255), + (int)Math.Clamp(c.B * k, 0, 255)); + Color Shade(float k) => Tint(baseColor, k); + + if (needle != null) + { + var nc = needleColor ?? baseColor; + using var nb = new LinearGradientBrush( + new PointF(-3f * hr, 0), new PointF(3f * hr, 0), Tint(nc, 1.16f), Tint(nc, 0.52f)); + g.FillPath(nb, needle); + } + + using (var shadow = new GraphicsPath()) + { + shadow.AddEllipse(head.X + hr * 0.16f, head.Y + hr * 0.42f, head.Width * 0.92f, head.Height * 0.92f); + using var sb = new PathGradientBrush(shadow) + { + CenterColor = Color.FromArgb(A(96), 0, 0, 0), + SurroundColors = [Color.FromArgb(0, 0, 0, 0)], + }; + g.FillPath(sb, shadow); + } + + using (var hp = new GraphicsPath()) + { + hp.AddEllipse(head); + using var pgb = new PathGradientBrush(hp) + { + + CenterPoint = new PointF(head.X + hr * 0.60f, head.Y + hr * 0.58f), + CenterColor = Shade(1.34f), + SurroundColors = [Shade(0.55f)], + }; + g.FillPath(pgb, hp); + } + + using (var spec = new GraphicsPath()) + { + spec.AddEllipse(head.X + hr * 0.34f, head.Y + hr * 0.30f, hr * 0.62f, hr * 0.62f); + using var sb = new PathGradientBrush(spec) + { + CenterColor = Color.FromArgb(A(215), 255, 255, 255), + SurroundColors = [Color.FromArgb(0, 255, 255, 255)], + }; + g.FillPath(sb, spec); + } + + using (var rim = new Pen(Color.FromArgb(A(70), 255, 255, 255), Math.Max(0.7f, hr * 0.11f))) + g.DrawArc(rim, head.X + 0.6f, head.Y + 0.6f, head.Width - 1.2f, head.Height - 1.2f, 20f, 130f); + } + + internal static void DrawPushpin(Graphics g, RectangleF r, bool pinned, float hover, float a, + bool recordable = false, float holdT = 0f) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + var st = g.Save(); + float cx = r.X + r.Width / 2f, cy = r.Y + r.Height / 2f, u = r.Width / 24f * 0.7f; + g.TranslateTransform(cx, cy); + g.RotateTransform(28f); + float hr = 6.4f * u; + var head = new RectangleF(-hr, -3f * u - hr, hr * 2, hr * 2); + using var needle = new GraphicsPath(); + needle.AddPolygon(new[] { new PointF(-2.3f * u, 2.5f * u), new PointF(2.3f * u, 2.5f * u), new PointF(0, 12f * u) }); + + float grow = 1f + 0.18f * holdT; + if (grow > 1.001f) + { + float gh = hr * grow; + head = new RectangleF(-gh, -3f * u - gh, gh * 2, gh * 2); + hr = gh; + } + + if (recordable) + { + + var amber = Color.FromArgb(255, 255, 200, 92); + if (pinned) + { + + Sphere(g, head, hr, needle, a, amber, Slate); + } + else + { + using (var pen = new Pen(Color.FromArgb((int)((122 + 78 * hover) * a), 255, 255, 255), 1.7f * u) + { LineJoin = LineJoin.Round, StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawPath(pen, needle); + Sphere(g, head, hr, null, a, amber); + } + } + else if (pinned) + { + Sphere(g, head, hr, needle, a, Slate); + } + else + { + int dim = (int)((122 + 78 * hover) * a); + using var pen = new Pen(Color.FromArgb(dim, 255, 255, 255), 1.7f * u) + { LineJoin = LineJoin.Round, StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawPath(pen, needle); + g.DrawEllipse(pen, head.X, head.Y, hr * 2, hr * 2); + } + g.Restore(st); + } + + private static float Toward(float v, float t, float step) + => v < t ? Math.Min(t, v + step) : Math.Max(t, v - step); + + private void DetectAgentCancel(IntPtr fg) + { + if ((Win32.GetAsyncKeyState(Win32.VK_ESCAPE) & 0x8000) == 0) return; + if (!ForegroundIsAgentHost(fg)) return; + if (_claudeStore.Current?.State == "compacting") + ClaudeCodeWidget.MarkCompactCancelled(_claudeStore.Current?.StartedAt); + if (_codexStore.Current?.State == "compacting") + CodexWidget.MarkCompactCancelled(_codexStore.Current?.StartedAt); + + if (_claudeStore.Current?.State == "working") + ClaudeCodeWidget.MarkTurnCancelled(_claudeStore.Current?.StartedAt); + if (_codexStore.Current?.State == "working") + CodexWidget.MarkTurnCancelled(_codexStore.Current?.StartedAt); + } + + private static string ProcessNameOf(IntPtr hwnd) + { + try + { + Win32.GetWindowThreadProcessId(hwnd, out uint pid); + if (pid == 0) return ""; + using var p = System.Diagnostics.Process.GetProcessById((int)pid); + return p.ProcessName; + } + catch { return ""; } + } + + private static bool ForegroundIsAgentHost(IntPtr fg) + { + try + { + Win32.GetWindowThreadProcessId(fg, out uint pid); + if (pid == 0) return false; + using var proc = System.Diagnostics.Process.GetProcessById((int)pid); + var name = proc.ProcessName.ToLowerInvariant(); + return name is "windowsterminal" or "wt" or "conhost" or "openconsole" or "powershell" + or "pwsh" or "cmd" or "bash" or "wsl" or "alacritty" or "wezterm-gui" or "code" + or "chatgpt" or "codex" || name.Contains("claude"); + } + catch + { + return false; + } + } + + private void CancelClaude(int slot) + { + var st = _claudeStore.SessionLive(slot); + var pid = st?.Pid ?? 0; + if (pid <= 0) return; + CcCancel.Request(pid); + + ClaudeCodeWidget.MarkTurnCancelled(st?.StartedAt); + } + + private void CancelCodex(CodexSurface surface) + { + var snapshot = _codexStore.Candidate(surface); + if (snapshot is { Source: CodexSurface.Cli, State: "working", ConsolePid: > 0 }) + CcCancel.Request(snapshot.ConsolePid); + else if (snapshot is { Source: CodexSurface.Desktop, State: "working" }) + _codexDesktopRuntime.TryCancel(); + else return; + + CodexWidget.MarkTurnCancelled(snapshot.StartedAt); + } + + private void OnClipboardImage(Bitmap shot, bool isScreenshot) + { + if (!Alert("clipboard")) return; + string path = ""; + try + { + path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"halo-shot-{DateTime.Now:yyyyMMdd-HHmmss-fff}.png"); + shot.Save(path, System.Drawing.Imaging.ImageFormat.Png); + } + catch { path = ""; } + _notifSrc.EnqueueLocal(new Halo.Notifications.NotifItem + { + App = isScreenshot ? Halo.Notifications.NotifItem.ScreenshotApp : Halo.Notifications.NotifItem.ClipboardApp, + Title = isScreenshot ? Halo.Notifications.NotifItem.ScreenshotTitle : Halo.Notifications.NotifItem.ImageCopiedTitle, + Preview = shot, + LaunchPath = path, + + Icon = isScreenshot ? Badges.Shot() : Badges.Clip(), + }); + } + + private void DetectLanguageChange(IntPtr fg) + { + try + { + uint tid = Win32.GetWindowThreadProcessId(fg, out _); + if (tid == 0) return; + uint lang = (uint)(Win32.GetKeyboardLayout(tid).ToInt64() & 0xFFFF); + if (lang == 0) return; + long now = Environment.TickCount64; + if (fg != _langFg) + { + _langFg = fg; _lastLangId = lang; _langFgSince = now; + return; + } + + if (_lastLangId != 0 && lang != _lastLangId && now - _langFgSince > 600 && Alert("language")) + ShowLanguageNotif(lang); + _lastLangId = lang; + } + catch { } + } + + private void ShowLanguageNotif(uint langId) + { + string name = "Keyboard", code = "?"; + try + { + var ci = new System.Globalization.CultureInfo((int)langId); + var lang = ci.Parent.EnglishName.Length > 0 ? ci.Parent.EnglishName : ci.EnglishName; + if (lang.Length > 0) name = lang; + code = ci.TwoLetterISOLanguageName.ToUpperInvariant(); + } + catch { } + var item = new Halo.Notifications.NotifItem + { + App = "Keyboard", Title = name, Icon = Badges.Language(code), + Kind = "language", Duration = 1, + }; + + if (_notif is { Kind: "language" } && !_notifClosing) + { + _notif.Icon?.Dispose(); + _notif = item; + _notifDeadline = DateTime.UtcNow.AddSeconds(1); + return; + } + _notifSrc.DropPending("language"); + _notifSrc.EnqueueLocal(item); + } + + internal static Halo.Notifications.NotifItem[] SampleLocalNotices(Bitmap shot) => new[] + { + new Halo.Notifications.NotifItem + { + App = Halo.Notifications.NotifItem.ScreenshotApp, + Title = Halo.Notifications.NotifItem.ScreenshotTitle, + Preview = shot, Icon = Badges.Shot(), + }, + new Halo.Notifications.NotifItem { App = "Network", Title = "Bad internet", Icon = Badges.NetSlow() }, + new Halo.Notifications.NotifItem + { + App = "Network", Title = "No internet", Body = "Nothing is getting out right now.", + Icon = Badges.NetDown(), + }, + new Halo.Notifications.NotifItem + { + App = "Claude", Title = "Claude is unreachable", Body = "Your connection is fine — theirs isn't.", + Icon = Badges.ApiDown(), + }, + new Halo.Notifications.NotifItem + { + App = "System", Title = "High CPU usage — 92%", Body = "chrome.exe is using the most.", + Icon = Badges.Cpu(), + }, + new Halo.Notifications.NotifItem + { + App = "System", Title = "High memory usage — 88%", Body = "Chrome is using the most.", + Icon = Badges.Memory(), + }, + new Halo.Notifications.NotifItem + { + App = "Battery", Title = "Battery critical — 7%", Body = "Tap to turn on Power Saver.", + Icon = Badges.BatteryDead(), + }, + new Halo.Notifications.NotifItem + { + App = "Claude", Title = "Context 85% full", + Body = "Answers get vaguer from here — /compact when you can.", Icon = Badges.Context(), + }, + new Halo.Notifications.NotifItem + { + App = "Claude", Title = "Claude usage 85%", Body = "You've used 85% of your weekly limit.", + Icon = Badges.LimitLong(), + }, + + new Halo.Notifications.NotifItem + { + App = "Tehran", + Title = Almanac.Headline(DateTime.Today.AddHours(1), new Almanac.Weather(27, 0, Day: false), metric: true), + Body = Almanac.Detail(DateTime.Today.AddHours(1), CalendarKind.SolarHijri), + Icon = Badges.Local(0xE708, 232, 32f), + }, + }; + + private int NotifLeft() => _notch.WorkLeft + (_notch.WorkWidth - Sc(_curW)) / 2 + (int)_offsetX; + + private static readonly string HaloDir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo"); + private static readonly string OffsetPath = System.IO.Path.Combine(HaloDir, "offset"); + private static readonly string GreetedPath = System.IO.Path.Combine(HaloDir, "greeted"); + private static readonly string StripOrderPath = System.IO.Path.Combine(HaloDir, "strip-order.txt"); + private static readonly string PinPath = System.IO.Path.Combine(HaloDir, "pinned"); + + private void LoadOffset() + { + try { if (float.TryParse(System.IO.File.ReadAllText(OffsetPath), System.Globalization.CultureInfo.InvariantCulture, out var v)) _offsetX = v; } + catch { } + + try + { + string legacy = System.IO.File.Exists(PinPath) && System.IO.File.ReadAllText(PinPath).Trim() == "1" + ? "on" : "off"; + _pinned = _settings.Current.Bool(Halo.Settings.SettingsKeys.OverFullscreen, legacy == "on"); + } + catch { } + } + + private DateTime _offsetStamp; + + private void ReloadOffset() + { + try + { + var stamp = System.IO.File.GetLastWriteTimeUtc(OffsetPath); + if (stamp == _offsetStamp) return; + _offsetStamp = stamp; + if (float.TryParse(System.IO.File.ReadAllText(OffsetPath), + System.Globalization.CultureInfo.InvariantCulture, out var v) && v != _offsetX) + _offsetX = v; + } + catch { } + } + + private void SaveOffset() + { + try + { + System.IO.File.WriteAllText(OffsetPath, _offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)); + _offsetStamp = System.IO.File.GetLastWriteTimeUtc(OffsetPath); + } + catch { } + } + + private void SavePin() + { + try { System.IO.File.WriteAllText(PinPath, _pinned ? "1" : "0"); } catch { } + try { _settings.Set(Halo.Settings.SettingsKeys.OverFullscreen, _pinned ? "on" : "off"); } catch { } + } + + private static readonly string RecordablePath = System.IO.Path.Combine(HaloDir, "capturable"); + private bool _recordable; + + private void LoadRecordable() + { + try + { + bool legacy = System.IO.File.Exists(RecordablePath) + && System.IO.File.ReadAllText(RecordablePath).Trim() == "1"; + _recordable = _settings.Current.Bool(Halo.Settings.SettingsKeys.InCaptures, legacy); + } + catch { } + } + + private void SaveRecordable() + { + try { System.IO.File.WriteAllText(RecordablePath, _recordable ? "1" : "0"); } catch { } + try { _settings.Set(Halo.Settings.SettingsKeys.InCaptures, _recordable ? "on" : "off"); } catch { } + } + + private bool PressOnControl(Win32.POINT p) + { + if (_progress <= 0.9f || _primary < 0 || _primary >= _widgets.Length) return false; + + if (OverPin(p)) return true; + try + { + foreach (var (r, _) in _widgets[_primary].Buttons(ExpandedW, ExpandedH)) + { + float bx = _el + r.X * S, by = _et + r.Y * S; + if (p.X >= bx - 6 * S && p.X < bx + (r.Width + 6) * S + && p.Y >= by - 8 * S && p.Y < by + (r.Height + 8) * S) return true; + } + } + catch { } + return false; + } + + private void UpdateMove(Win32.POINT p, bool down, bool hovered) + { + int centre = _notch.WorkLeft + _notch.WorkWidth / 2; + const float snap = 55f; + if (_moving) + { + if (down) + { + float raw = Math.Clamp(p.X - _moveGrabDX - centre, + -(_notch.WorkWidth / 2f - Sc(CollapsedW) / 2f - 8), _notch.WorkWidth / 2f - Sc(CollapsedW) / 2f - 8); + _offsetX = MathF.Abs(raw) < snap ? 0f : raw; + } + else { if (MathF.Abs(_offsetX) < snap) _offsetX = 0f; _moving = false; _holdT = 0f; SaveOffset(); } + return; + } + + bool holding = down && hovered && !_resizing && _notif == null + && !FileTray.DragActive && _trayPressPath == null && _trayMode < 1 + && !PressOnControl(p); + bool still = Math.Abs(p.X - _holdAnchor.X) <= 8 && Math.Abs(p.Y - _holdAnchor.Y) <= 8; + if (holding && _holdStart != DateTime.MaxValue && still) + { + _holdT = Math.Clamp((float)((DateTime.UtcNow - _holdStart).TotalSeconds / HoldSeconds), 0f, 1f); + if (_holdT >= 1f) { _moving = true; _moveGrabDX = p.X - (int)(centre + _offsetX); _holdStart = DateTime.MaxValue; } + } + else if (holding) { _holdStart = DateTime.UtcNow; _holdAnchor = p; _holdT = 0f; } + else { _holdStart = DateTime.MaxValue; _holdT = 0f; } + } + + private void DrawHoldCue(Graphics g, int w, int h) + { + float t = SmoothStep(_holdT); + float bw = (w - 64) * t; + if (bw < 3f) return; + g.SmoothingMode = SmoothingMode.AntiAlias; + var rect = new RectangleF((w - bw) / 2f, h - 6f, bw, 2.5f); + using var p = Fx.Rounded(rect, 1.25f); + using var br = new System.Drawing.Drawing2D.LinearGradientBrush( + new RectangleF(rect.X - 0.5f, rect.Y, rect.Width + 1f, rect.Height), + Color.White, Color.White, 0f); + int peak = 25 + (int)(110 * t); + br.InterpolationColors = new System.Drawing.Drawing2D.ColorBlend(3) + { + Colors = new[] { Color.FromArgb(0, 255, 255, 255), Color.FromArgb(peak, 255, 255, 255), Color.FromArgb(0, 255, 255, 255) }, + Positions = new[] { 0f, 0.5f, 1f }, + }; + g.FillPath(br, p); + } + + private static DateTime Max(DateTime a, DateTime b) => a > b ? a : b; + + private static float SmoothStep(float t) => t * t * (3f - 2f * t); + + private static float Lerp(float a, float b, float t) => a + (b - a) * t; + + private static float EaseOutBack(float t) + { + const float c1 = 1.2f; + const float c3 = c1 + 1f; + float p = t - 1f; + return 1f + c3 * MathF.Pow(p, 3f) + c1 * MathF.Pow(p, 2f); + } +} diff --git a/src/Halo.App/Shell/NotchGeometry.cs b/src/Halo.App/Shell/NotchGeometry.cs new file mode 100644 index 0000000..a1a68d8 --- /dev/null +++ b/src/Halo.App/Shell/NotchGeometry.cs @@ -0,0 +1,10 @@ +namespace Halo.Shell; + +public static class NotchGeometry +{ + public static (int x, int y, int w, int h) CollapsedRect(int workLeft, int workTop, int workWidth, int collapsedWidth, int collapsedHeight) + => (workLeft + (workWidth - collapsedWidth) / 2, workTop, collapsedWidth, collapsedHeight); + + public static (int x, int y, int w, int h) ExpandedRect(int workLeft, int workTop, int workWidth, int expandedWidth, int expandedHeight) + => (workLeft + (workWidth - expandedWidth) / 2, workTop, expandedWidth, expandedHeight); +} diff --git a/src/Halo.App/Shell/StripOrder.cs b/src/Halo.App/Shell/StripOrder.cs new file mode 100644 index 0000000..a3fd93f --- /dev/null +++ b/src/Halo.App/Shell/StripOrder.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Halo.Shell; + +internal sealed class StripOrder +{ + private readonly List _pinned = []; + + internal IReadOnlyList Pinned => _pinned; + + internal StripOrder() { } + + internal StripOrder(IEnumerable pinned) + { + foreach (var k in pinned) + if (!string.IsNullOrWhiteSpace(k) && !_pinned.Contains(k)) _pinned.Add(k.Trim()); + } + + internal List Apply(IReadOnlyList present) + { + var seen = new HashSet(StringComparer.Ordinal); + var here = new HashSet(present, StringComparer.Ordinal); + var result = new List(present.Count); + foreach (var k in _pinned) + if (here.Contains(k) && seen.Add(k)) result.Add(k); + foreach (var k in present) + if (seen.Add(k)) result.Add(k); + return result; + } + + internal bool Move(IReadOnlyList present, string kind, int delta) + { + if (delta == 0) return false; + var view = Apply(present); + int at = view.IndexOf(kind); + if (at < 0) return false; + int to = Math.Clamp(at + delta, 0, view.Count - 1); + if (to == at) return false; + + view.RemoveAt(at); + view.Insert(to, kind); + + foreach (var k in view) + _pinned.Remove(k); + _pinned.InsertRange(0, view); + return true; + } + + internal string Serialise() => string.Join('\n', _pinned); + + internal static StripOrder Load(string path) + { + try + { + return File.Exists(path) + ? new StripOrder(File.ReadAllLines(path)) + : new StripOrder(); + } + catch { return new StripOrder(); } + } + + internal void Save(string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, Serialise()); + } + catch { } + } +} diff --git a/src/Halo.App/Shell/TrayIcon.cs b/src/Halo.App/Shell/TrayIcon.cs new file mode 100644 index 0000000..bd59401 --- /dev/null +++ b/src/Halo.App/Shell/TrayIcon.cs @@ -0,0 +1,143 @@ +using System; +using System.Runtime.InteropServices; +using Halo.Interop; + +namespace Halo.Shell; + +internal sealed class TrayIcon : IDisposable +{ + private const uint WM_TRAY = 0x0400 + 1; + private const uint WM_LBUTTONUP = 0x0202, WM_RBUTTONUP = 0x0205, WM_CONTEXTMENU = 0x007B; + private const int IdSettings = 1, IdRestart = 2, IdQuit = 3; + + private readonly Win32.WndProc _proc; + private readonly IntPtr _hwnd; + private readonly uint _taskbarCreated; + private IntPtr _icon; + private bool _added; + + internal TrayIcon() + { + _proc = Handle; + var wc = new Win32.WNDCLASSEX + { + cbSize = Marshal.SizeOf(), + lpfnWndProc = _proc, + hInstance = Win32.GetModuleHandle(null), + lpszClassName = "HaloTrayWindow", + }; + Win32.RegisterClassEx(ref wc); + _hwnd = Win32.CreateWindowEx(0, "HaloTrayWindow", "Halo", 0, 0, 0, 0, 0, + Win32.HWND_MESSAGE, IntPtr.Zero, wc.hInstance, IntPtr.Zero); + + _taskbarCreated = Win32.RegisterWindowMessage("TaskbarCreated"); + Add(); + } + + private static IntPtr LoadAppIcon() + { + try + { + int size = Math.Max(16, Win32.GetSystemMetrics(49 )); + var handles = new IntPtr[1]; + var ids = new int[1]; + string exe = Environment.ProcessPath ?? ""; + if (exe.Length > 0 && Win32.PrivateExtractIcons(exe, 0, size, size, handles, ids, 1, 0) >= 1) + return handles[0]; + } + catch { } + return IntPtr.Zero; + } + + private void Add() + { + try + { + if (_icon == IntPtr.Zero) _icon = LoadAppIcon(); + var data = Data(); + data.uFlags = Win32.NIF_MESSAGE | Win32.NIF_ICON | Win32.NIF_TIP | Win32.NIF_SHOWTIP; + data.uCallbackMessage = (int)WM_TRAY; + data.hIcon = _icon; + data.szTip = "Halo"; + _added = Win32.Shell_NotifyIcon(Win32.NIM_ADD, ref data); + + var version = Data(); + version.uVersion = Win32.NOTIFYICON_VERSION_4; + Win32.Shell_NotifyIcon(Win32.NIM_SETVERSION, ref version); + } + catch { } + } + + private Win32.NOTIFYICONDATA Data() => new() + { + cbSize = Marshal.SizeOf(), + hWnd = _hwnd, + uID = 1, + szTip = "", + szInfo = "", + szInfoTitle = "", + }; + + private IntPtr Handle(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam) + { + try + { + if (msg == _taskbarCreated && _taskbarCreated != 0) { Add(); return IntPtr.Zero; } + if (msg == WM_TRAY) + { + + uint evt = (uint)((long)lParam & 0xFFFF); + if (evt == WM_LBUTTONUP) Program.OpenSettings(); + else if (evt is WM_RBUTTONUP or WM_CONTEXTMENU) + Menu((short)((long)wParam & 0xFFFF), (short)(((long)wParam >> 16) & 0xFFFF)); + return IntPtr.Zero; + } + } + catch { } + return Win32.DefWindowProc(hwnd, msg, wParam, lParam); + } + + private void Menu(int x, int y) + { + IntPtr menu = IntPtr.Zero; + try + { + menu = Win32.CreatePopupMenu(); + if (menu == IntPtr.Zero) return; + Win32.AppendMenu(menu, Win32.MF_STRING, IdSettings, "Open settings"); + Win32.AppendMenu(menu, Win32.MF_SEPARATOR, 0, null); + Win32.AppendMenu(menu, Win32.MF_STRING, IdRestart, "Restart Halo"); + Win32.AppendMenu(menu, Win32.MF_STRING, IdQuit, "Quit Halo"); + + Win32.SetForegroundWindow(_hwnd); + int picked = Win32.TrackPopupMenuEx(menu, + Win32.TPM_RIGHTBUTTON | Win32.TPM_RETURNCMD, x, y, _hwnd, IntPtr.Zero); + Win32.PostMessage(_hwnd, 0x0000 , IntPtr.Zero, IntPtr.Zero); + + switch (picked) + { + case IdSettings: Program.OpenSettings(); break; + case IdRestart: Program.Restart(); break; + case IdQuit: Program.Quit(); break; + } + } + catch { } + finally { if (menu != IntPtr.Zero) Win32.DestroyMenu(menu); } + } + + public void Dispose() + { + try + { + if (_added) + { + var data = Data(); + Win32.Shell_NotifyIcon(Win32.NIM_DELETE, ref data); + _added = false; + } + if (_icon != IntPtr.Zero) { Win32.DestroyIcon(_icon); _icon = IntPtr.Zero; } + if (_hwnd != IntPtr.Zero) Win32.DestroyWindow(_hwnd); + } + catch { } + } +} diff --git a/src/Halo.App/Widgets/AppIcon.cs b/src/Halo.App/Widgets/AppIcon.cs new file mode 100644 index 0000000..ee00315 --- /dev/null +++ b/src/Halo.App/Widgets/AppIcon.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; + +namespace Halo.Widgets; + +internal static class AppIcon +{ + private static readonly object _lock = new(); + private static readonly Dictionary _ok = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _missed = new(StringComparer.OrdinalIgnoreCase); + + public static Bitmap? ForSessionApp(string? aumid) + => string.IsNullOrEmpty(aumid) ? null + : Halo.Notifications.ShellIcon.ForAumid(aumid) ?? ForAumid(aumid); + + public static Bitmap? ForAumid(string? aumid) + { + if (string.IsNullOrEmpty(aumid)) return null; + lock (_lock) + { + if (_ok.TryGetValue(aumid, out var cached)) return cached; + if (_missed.TryGetValue(aumid, out var t) && Environment.TickCount64 - t < 3000) return null; + var bmp = Resolve(aumid); + if (bmp != null) _ok[aumid] = bmp; else _missed[aumid] = Environment.TickCount64; + return bmp; + } + } + + private static Bitmap? Resolve(string aumid) + { + try + { + string? exe = ExeFromAumid(aumid); + if (exe == null || !File.Exists(exe)) return null; + return LargeIcon(exe) ?? Icon.ExtractAssociatedIcon(exe)?.ToBitmap(); + } + catch { return null; } + } + + private static Bitmap? LargeIcon(string exe) + { + var h = new IntPtr[1]; + var id = new int[1]; + if (Halo.Interop.Win32.PrivateExtractIcons(exe, 0, 256, 256, h, id, 1, 0) < 1 || h[0] == IntPtr.Zero) + return null; + try { using var ico = Icon.FromHandle(h[0]); return ico.ToBitmap(); } + finally { Halo.Interop.Win32.DestroyIcon(h[0]); } + } + + private static string? ExeFromAumid(string aumid) + { + if (aumid.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) && File.Exists(aumid)) return aumid; + string key = Path.GetFileNameWithoutExtension(aumid); + foreach (var p in Process.GetProcesses()) + { + try + { + string pn = p.ProcessName; + if (pn.Length > 1 && + (aumid.Contains(pn, StringComparison.OrdinalIgnoreCase) || pn.Contains(key, StringComparison.OrdinalIgnoreCase))) + { + var f = p.MainModule?.FileName; + if (f != null) return f; + } + } + catch { } + finally { p.Dispose(); } + } + return null; + } +} diff --git a/src/Halo.App/Widgets/AskBanner.cs b/src/Halo.App/Widgets/AskBanner.cs new file mode 100644 index 0000000..81c2058 --- /dev/null +++ b/src/Halo.App/Widgets/AskBanner.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using Halo.ClaudeCode; + +namespace Halo.Widgets; + +internal static class AskBanner +{ + internal const int W = 470; + + internal const int DeskTint = 200; + private const float Pad = 20f; + private const float IconD = 19f, IconGap = 8f; + private const float EyebrowTop = 18f, EyebrowH = 16f, EyebrowPx = 11.5f; + private const float TitleTop = 44f, TitlePx = 19f, TitleLineH = 25f; + private const float TargetPx = 13f, TargetH = 19f; + private const float TitleGap = 14f; + + private const float RowGap = 8f, RowRadius = 16f, RowPadX = 14f, RowPadY = 11f; + private const float MinRowH = 50f; + + private const float NumD = 32f, NumGap = 11f, NumPx = 16f; + private const float BottomPad = 20f; + private const float LabelPx = 15f, DescPx = 12.5f; + + private const float LabelLineH = 20f, DescLineH = 17f, LabelDescGap = 2f; + private const int TitleMaxLines = 3, LabelMaxLines = 2, DescMaxLines = 3; + + private static readonly Color White = Color.FromArgb(255, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(175, 255, 255, 255); + private static readonly Color DimClear = Color.FromArgb(228, 255, 255, 255); + private static readonly Color TargetInk = Color.FromArgb(222, 255, 255, 255); + private static readonly Color Amber = Color.FromArgb(255, 176, 32); + private static readonly Color Green = Color.FromArgb(62, 207, 92); + private static readonly Color Red = Color.FromArgb(229, 72, 77); + + private static bool HasTarget(PendingAsk ask) => !ask.IsQuestion && !string.IsNullOrEmpty(ask.Target); + + internal static readonly AskOption Other = new("Chat about this", "say it in your own words"); + + internal static bool IsOther(AskOption option) => ReferenceEquals(option, Other); + + private static bool HasOther(PendingAsk ask) => ask.IsQuestion; + + internal sealed record AskRow( + RectangleF Rect, RectangleF Body, RectangleF Label, RectangleF Desc, AskOption Option); + internal sealed record AskLayout( + RectangleF Title, RectangleF Target, IReadOnlyList Rows, int Height); + + private static readonly object LayoutLock = new(); + private static Graphics? _measure; + private static PendingAsk? _memoAsk; + private static int _memoW; + private static AskLayout? _memo; + + internal static AskLayout Layout(PendingAsk ask, int w) + { + lock (LayoutLock) + { + if (_memo != null && ReferenceEquals(_memoAsk, ask) && _memoW == w) return _memo; + _memo = Build(ask, w); + _memoAsk = ask; + _memoW = w; + return _memo; + } + } + + private static AskLayout Build(PendingAsk ask, int w) + { + float inner = w - Pad * 2; + using var tf = new Font("Segoe UI Semibold", TitlePx, GraphicsUnit.Pixel); + var title = new RectangleF(Pad, TitleTop, inner, Lines(Title(ask), tf, inner, TitleMaxLines) * TitleLineH); + var target = new RectangleF(Pad, title.Bottom, inner, HasTarget(ask) ? TargetH : 0f); + + float bodyX = Pad + NumD + NumGap; + float textX = bodyX + RowPadX, textW = w - Pad - RowPadX - textX; + float y = target.Bottom + TitleGap; + + using var lf = new Font("Segoe UI Semibold", LabelPx, GraphicsUnit.Pixel); + using var df = new Font("Segoe UI", DescPx, GraphicsUnit.Pixel); + var rows = new List(); + var options = new List(ask.Options); + if (HasOther(ask)) options.Add(Other); + foreach (var option in options) + { + float labelH = Lines(option.Label, lf, textW, LabelMaxLines) * LabelLineH; + bool hasDesc = !string.IsNullOrWhiteSpace(option.Description); + float descH = hasDesc ? Lines(option.Description, df, textW, DescMaxLines) * DescLineH : 0f; + float stack = labelH + (hasDesc ? LabelDescGap + descH : 0f); + float rowH = MathF.Max(MinRowH, stack + RowPadY * 2); + + float top = y + (rowH - stack) / 2f; + var label = new RectangleF(textX, top, textW, labelH); + rows.Add(new AskRow( + new RectangleF(Pad, y, inner, rowH), + new RectangleF(bodyX, y, w - Pad - bodyX, rowH), + label, + hasDesc ? new RectangleF(textX, label.Bottom + LabelDescGap, textW, descH) : RectangleF.Empty, + option)); + y += rowH + RowGap; + } + + float bottom = rows.Count > 0 ? rows[^1].Rect.Bottom : y + MinRowH; + return new AskLayout(title, target, rows, (int)MathF.Ceiling(bottom + BottomPad)); + } + + private static int Lines(string? text, Font font, float width, int max) + { + if (string.IsNullOrWhiteSpace(text) || width <= 1f) return 1; + try + { + _measure ??= Graphics.FromImage(new Bitmap(1, 1, PixelFormat.Format32bppPArgb)); + _measure.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + using var sf = Wrap(StringAlignment.Near); + _measure.MeasureString(text, font, new SizeF(width, 4000f), sf, out _, out int lines); + return Math.Clamp(lines, 1, max); + } + catch { return 1; } + } + + internal static List<(RectangleF Rect, AskOption Option)> Chips(PendingAsk ask, int w) + { + var result = new List<(RectangleF, AskOption)>(); + foreach (var row in Layout(ask, w).Rows) result.Add((row.Rect, row.Option)); + return result; + } + + internal static int Height(PendingAsk ask, int w) => Layout(ask, w).Height; + + internal static void Draw(Graphics g, int w, int h, float a, PendingAsk ask, int hover, + int tint = DeskTint, string? typed = null) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; + + bool seeThrough = tint < DeskTint; + var layout = Layout(ask, w); + DrawEyebrow(g, w, a, ask, seeThrough); + + using (var tf = new Font("Segoe UI Semibold", TitlePx, GraphicsUnit.Pixel)) + using (var sf = Wrap(StringAlignment.Center)) + InkRtl(g, Title(ask), tf, Slack(layout.Title), sf, White, a, seeThrough); + + if (HasTarget(ask)) + using (var gf = new Font("Consolas", TargetPx, GraphicsUnit.Pixel)) + using (var sf = Centre()) + Ink(g, ask.Target!, gf, layout.Target, sf, TargetInk, a, seeThrough); + + for (int i = 0; i < layout.Rows.Count; i++) + { + var row = layout.Rows[i]; + bool typing = typed != null && IsOther(row.Option); + DrawRow(g, row, i + 1, a, typing || i == hover, Accent(ask, row.Option.Label), seeThrough, + typing ? typed : null); + } + } + + private static void DrawEyebrow(Graphics g, int w, float a, PendingAsk ask, bool seeThrough) + { + string label = Eyebrow(ask); + using var ef = new Font("Segoe UI Semibold", EyebrowPx, GraphicsUnit.Pixel); + float textW = g.MeasureString(label, ef, int.MaxValue, StringFormat.GenericTypographic).Width; + var icon = ClaudeCodeWidget.PlainIcon; + float groupW = textW + (icon != null ? IconD + IconGap : 0f); + float x = (w - groupW) / 2f; + + if (icon != null) + { + DrawRoundIcon(g, icon, x, EyebrowTop + (EyebrowH - IconD) / 2f, IconD, a); + x += IconD + IconGap; + } + using var sf = new StringFormat(StringFormat.GenericTypographic) + { FormatFlags = StringFormatFlags.NoWrap, LineAlignment = StringAlignment.Center }; + Ink(g, label, ef, new RectangleF(x, EyebrowTop, textW + 4, EyebrowH), sf, + seeThrough ? DimClear : Dim, a, seeThrough); + } + + private static Color Accent(PendingAsk ask, string label) + { + if (ask.IsQuestion) return Amber; + return label switch { "allow" => Green, "deny" => Red, _ => Amber }; + } + + private static void DrawRow(Graphics g, AskRow row, int number, float a, bool hover, Color accent, + bool seeThrough, string? typed) + { + var r = row.Rect; + var numRect = new RectangleF(r.X, r.Y + (r.Height - NumD) / 2f, NumD, NumD); + DrawVessel(g, row.Body, a, hover, accent, seeThrough); + + DrawBead(g, numRect, a, hover, seeThrough); + + DrawGlyph(g, number.ToString(), numRect, + Color.FromArgb((int)(a * (hover ? 225 : 170)), 255, 255, 255), seeThrough ? a : 0f); + + using var sf = Wrap(StringAlignment.Near); + using var lf = new Font("Segoe UI Semibold", LabelPx, GraphicsUnit.Pixel); + using var df = new Font("Segoe UI", DescPx, GraphicsUnit.Pixel); + + if (typed != null) + { + + string shown = Tail(g, typed, lf, row.Label.Width - CaretW - 2f); + + bool rtl = Fx.IsRtl(shown); + using var tsf = rtl ? Wrap(StringAlignment.Near) : null; + var fmt = tsf ?? sf; + if (rtl) fmt.FormatFlags |= StringFormatFlags.DirectionRightToLeft; + + Ink(g, shown, lf, Slack(row.Label), fmt, White, a, seeThrough); + float run = shown.Length == 0 ? 0f + : g.MeasureString(shown, lf, int.MaxValue, StringFormat.GenericTypographic).Width; + float caretX = rtl ? row.Label.Right - run - CaretW - 1f : row.Label.X + run + 1f; + using (var cb = new SolidBrush(Mul(accent, a))) + g.FillRectangle(cb, caretX, row.Label.Y + 1f, CaretW, LabelLineH - 4f); + if (row.Desc.Height > 0f) + Ink(g, "enter to send esc to go back", df, Slack(row.Desc), sf, + seeThrough ? DimClear : Dim, a, seeThrough); + return; + } + + InkRtl(g, row.Option.Label, lf, Slack(row.Label), sf, White, a, seeThrough); + if (row.Desc.Height <= 0f) return; + + InkRtl(g, row.Option.Description, df, Slack(row.Desc), sf, seeThrough ? DimClear : Dim, a, seeThrough); + } + + private const float CaretW = 2f; + + private static string Tail(Graphics g, string text, Font f, float width) + { + if (text.Length == 0 || width <= 4f) return text; + int start = 0; + while (start < text.Length && + g.MeasureString(text[start..], f, int.MaxValue, StringFormat.GenericTypographic).Width > width) + start++; + return text[start..]; + } + + private static readonly PointF[] Halo = + [new(-1f, 0f), new(1f, 0f), new(0f, -1f), new(0f, 1f)]; + + private static void InkRtl(Graphics g, string text, Font f, RectangleF r, StringFormat sf, Color c, + float a, bool seeThrough) + { + if (!Fx.IsRtl(text)) { Ink(g, text, f, r, sf, c, a, seeThrough); return; } + using var rsf = new StringFormat(sf) { FormatFlags = sf.FormatFlags | StringFormatFlags.DirectionRightToLeft }; + Ink(g, text, f, r, rsf, c, a, seeThrough); + } + + private static void Ink(Graphics g, string text, Font f, RectangleF r, StringFormat sf, Color c, + float a, bool seeThrough) + { + if (seeThrough) + { + using var sh = new SolidBrush(Color.FromArgb((int)(a * 72), 0, 0, 0)); + foreach (var d in Halo) + g.DrawString(text, f, sh, new RectangleF(r.X + d.X, r.Y + d.Y, r.Width, r.Height), sf); + } + using var b = new SolidBrush(Mul(c, a)); + g.DrawString(text, f, b, r, sf); + } + + private static Color Body(float a, bool hover) + => Color.FromArgb((int)(a * (hover ? 18 : 7)), 255, 255, 255); + + private static void DrawVessel(Graphics g, RectangleF r, float a, bool hover, Color accent, bool seeThrough) + { + using var path = Rounded(r, RowRadius); + using (var fill = new SolidBrush(Body(a, hover))) + g.FillPath(fill, path); + + var clip = g.Clip; + g.SetClip(path); + Streak(g, r.X + RowRadius, r.Right - RowRadius, r.Y + 2.6f, a * (hover ? 1.25f : 1f), 132, 1.5f); + Streak(g, r.X + RowRadius * 1.6f, r.Right - RowRadius * 1.6f, r.Bottom - 2.6f, + a * (hover ? 1.25f : 1f), 58, 1.2f); + g.Clip = clip; + + using var rimBrush = new LinearGradientBrush( + new RectangleF(r.X, r.Y - 1, r.Width, r.Height + 2), + Color.FromArgb((int)(a * (hover ? 165 : 128)), 255, 255, 255), + Color.FromArgb((int)(a * (hover ? 96 : 58)), 255, 255, 255), 90f); + using var pen = hover ? new Pen(Mul(accent, a * 0.9f), 0.9f) : new Pen(rimBrush, 0.7f); + g.DrawPath(pen, path); + } + + private static void Streak(Graphics g, float x0, float x1, float y, float a, int peak, float width) + { + if (x1 - x0 < 4f) return; + using var brush = new LinearGradientBrush( + new RectangleF(x0, y - 2f, x1 - x0, 4f), Color.Transparent, Color.Transparent, 0f) + { + InterpolationColors = new ColorBlend + { + Colors = + [ + Color.FromArgb(0, 255, 255, 255), + Color.FromArgb((int)Math.Clamp(a * peak, 0, 255), 255, 255, 255), + Color.FromArgb(0, 255, 255, 255), + ], + Positions = [0f, 0.42f, 1f], + }, + }; + using var pen = new Pen(brush, width); + g.DrawLine(pen, x0, y, x1, y); + } + + private static void DrawBead(Graphics g, RectangleF box, float a, bool hover, bool seeThrough) + { + using var circle = new GraphicsPath(); + circle.AddEllipse(box); + using (var fill = new SolidBrush(Body(a, hover))) + g.FillPath(fill, circle); + + using var rimBrush = new LinearGradientBrush( + new RectangleF(box.X, box.Y - 1, box.Width, box.Height + 2), + Color.FromArgb((int)(a * (hover ? 168 : 130)), 255, 255, 255), + Color.FromArgb((int)(a * (hover ? 92 : 56)), 255, 255, 255), 90f); + using var rim = new Pen(rimBrush, 0.7f); + g.DrawEllipse(rim, box); + } + + private static void DrawGlyph(Graphics g, string text, RectangleF box, Color ink, float shadow) + { + try + { + using var path = new GraphicsPath(); + using var family = new FontFamily("Consolas"); + path.AddString(text, family, (int)FontStyle.Bold, NumPx, PointF.Empty, StringFormat.GenericTypographic); + + using var probe = (GraphicsPath)path.Clone(); + probe.Flatten(); + var b = probe.GetBounds(); + if (b.Width <= 0 || b.Height <= 0) return; + using var m = new Matrix(); + m.Translate(MathF.Round(box.X + (box.Width - b.Width) / 2f - b.X), + MathF.Round(box.Y + (box.Height - b.Height) / 2f - b.Y)); + path.Transform(m); + if (shadow > 0.004f) + { + using var sb = new SolidBrush(Color.FromArgb((int)(shadow * 105), 0, 0, 0)); + foreach (var d in Halo) + { + using var sm = new Matrix(); + sm.Translate(d.X, d.Y); + using var sp = (GraphicsPath)path.Clone(); + sp.Transform(sm); + g.FillPath(sb, sp); + } + } + using var brush = new SolidBrush(ink); + g.FillPath(brush, path); + } + catch { } + } + + private static void DrawRoundIcon(Graphics g, Bitmap img, float x, float y, float d, float a) + { + var circle = new RectangleF(x, y, d, d); + int s = Math.Max(1, (int)Math.Ceiling(d)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = a }); + int side = Math.Min(img.Width, img.Height); + sg.DrawImage(img, new Rectangle(0, 0, s, s), + (img.Width - side) / 2, (img.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(circle.X, circle.Y); + using var p = new GraphicsPath(); + p.AddEllipse(circle); + g.FillPath(tb, p); + } + + private static StringFormat Wrap(StringAlignment align) => new(StringFormat.GenericTypographic) + { + Alignment = align, + LineAlignment = StringAlignment.Near, + FormatFlags = 0, + Trimming = StringTrimming.EllipsisCharacter, + }; + + private static RectangleF Slack(RectangleF r) => new(r.X, r.Y, r.Width, r.Height + 3f); + + private static StringFormat Centre() => new(StringFormat.GenericTypographic) + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + FormatFlags = StringFormatFlags.NoWrap, + Trimming = StringTrimming.EllipsisCharacter, + }; + + private static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = radius * 2; + var p = new GraphicsPath(); + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private static string Eyebrow(PendingAsk ask) + => ask.IsQuestion ? "CLAUDE CODE ASKS" : $"CLAUDE CODE WANTS TO RUN {ask.Tool.ToUpperInvariant()}"; + + private static string Title(PendingAsk ask) + => !string.IsNullOrEmpty(ask.Question) ? ask.Question! + : ask.IsQuestion ? "your move ;)" : "run this?"; + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); +} diff --git a/src/Halo.App/Widgets/AudioMeter.cs b/src/Halo.App/Widgets/AudioMeter.cs new file mode 100644 index 0000000..315e8fd --- /dev/null +++ b/src/Halo.App/Widgets/AudioMeter.cs @@ -0,0 +1,131 @@ +using System; +using System.Runtime.InteropServices; + +namespace Halo.Widgets; + +internal sealed class AudioMeter +{ + private IAudioMeterInformation? _meterI; + private IAudioEndpointVolume? _vol; + private static Guid _ctx = Guid.Empty; + private string? _boundId; + private long _nextDeviceCheck; + + public AudioMeter() => TryAcquire(); + + public float Peak() + { + DropIfDeviceChanged(); + if (_meterI == null) { TryAcquire(); if (_meterI == null) return 0f; } + try { _meterI!.GetPeakValue(out float p); return p; } + catch { _meterI = null; return 0f; } + } + + public float Volume() + { + DropIfDeviceChanged(); + if (_vol == null) { TryAcquire(); if (_vol == null) return 0f; } + try { _vol!.GetMasterVolumeLevelScalar(out float v); return v; } + catch { _vol = null; return 0f; } + } + + public bool Muted() + { + DropIfDeviceChanged(); + if (_vol == null) return false; + try { _vol!.GetMute(out bool m); return m; } + catch { _vol = null; return false; } + } + + public void SetVolume(float v) + { + DropIfDeviceChanged(); + if (_vol == null) { TryAcquire(); if (_vol == null) return; } + try { _vol!.SetMasterVolumeLevelScalar(Math.Clamp(v, 0f, 1f), ref _ctx); } + catch { _vol = null; } + } + + public void ToggleMute() + { + DropIfDeviceChanged(); + if (_vol == null) { TryAcquire(); if (_vol == null) return; } + try { _vol!.GetMute(out bool m); _vol.SetMute(!m, ref _ctx); } + catch { _vol = null; } + } + + private void DropIfDeviceChanged() + { + try + { + if (Environment.TickCount64 < _nextDeviceCheck) return; + _nextDeviceCheck = Environment.TickCount64 + 1000; + if (_boundId is null) return; + var en = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + if (en.GetDefaultAudioEndpoint(0, 1, out var dev) != 0 || dev == null) return; + if (dev.GetId(out var id) != 0 || id == _boundId) return; + _meterI = null; + _vol = null; + _boundId = null; + } + catch { } + } + + private void TryAcquire() + { + try + { + var en = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + if (en.GetDefaultAudioEndpoint(0, 1, out var dev) != 0 || dev == null) return; + if (dev.GetId(out var id) == 0) _boundId = id; + var mid = typeof(IAudioMeterInformation).GUID; + if (dev.Activate(ref mid, 23, IntPtr.Zero, out var mo) == 0) _meterI = mo as IAudioMeterInformation; + var vid = typeof(IAudioEndpointVolume).GUID; + if (dev.Activate(ref vid, 23, IntPtr.Zero, out var vo) == 0) _vol = vo as IAudioEndpointVolume; + } + catch { _meterI = null; _vol = null; } + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + private class MMDeviceEnumerator { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, uint clsCtx, IntPtr activationParams, + [MarshalAs(UnmanagedType.IUnknown)] out object iface); + + [PreserveSig] int OpenPropertyStore(uint access, out IntPtr store); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + } + + [ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioMeterInformation + { + [PreserveSig] int GetPeakValue(out float peak); + } + + [ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioEndpointVolume + { + [PreserveSig] int RegisterControlChangeNotify(IntPtr notify); + [PreserveSig] int UnregisterControlChangeNotify(IntPtr notify); + [PreserveSig] int GetChannelCount(out uint count); + [PreserveSig] int SetMasterVolumeLevel(float levelDb, ref Guid ctx); + [PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx); + [PreserveSig] int GetMasterVolumeLevel(out float levelDb); + [PreserveSig] int GetMasterVolumeLevelScalar(out float level); + [PreserveSig] int SetChannelVolumeLevel(uint ch, float levelDb, ref Guid ctx); + [PreserveSig] int SetChannelVolumeLevelScalar(uint ch, float level, ref Guid ctx); + [PreserveSig] int GetChannelVolumeLevel(uint ch, out float levelDb); + [PreserveSig] int GetChannelVolumeLevelScalar(uint ch, out float level); + [PreserveSig] int SetMute([MarshalAs(UnmanagedType.Bool)] bool mute, ref Guid ctx); + [PreserveSig] int GetMute([MarshalAs(UnmanagedType.Bool)] out bool mute); + } +} diff --git a/src/Halo.App/Widgets/AudioSpectrum.cs b/src/Halo.App/Widgets/AudioSpectrum.cs new file mode 100644 index 0000000..d400794 --- /dev/null +++ b/src/Halo.App/Widgets/AudioSpectrum.cs @@ -0,0 +1,281 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Halo.Widgets; + +internal static class AudioSpectrum +{ + public const int BandCount = 9; + private const int Ch = 5; + private static readonly float[] _bands = new float[BandCount]; + public static volatile bool Available; + + private static Thread? _thread; + private static long _until; + + public static float[] Bands() + { + _until = Environment.TickCount64 + 5000; + if (_thread == null) + { + _thread = new Thread(Loop) { IsBackground = true, Priority = ThreadPriority.BelowNormal }; + _thread.Start(); + } + lock (_bands) return (float[])_bands.Clone(); + } + + private const int N = 1024; + private static readonly float[] _ringL = new float[N * 2], _ringR = new float[N * 2]; + private static int _ringPos; + + private static void Loop() + { + while (true) + { + try + { + if (Environment.TickCount64 > _until) { Available = false; Thread.Sleep(300); continue; } + Capture(); + } + catch { Available = false; } + Thread.Sleep(500); + } + } + + private static string? DefaultRenderId() + { + try + { + var en = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + if (en.GetDefaultAudioEndpoint(0, 1, out var dev) != 0 || dev == null) return null; + return dev.GetId(out var id) == 0 ? id : null; + } + catch { return null; } + } + + private static void Capture() + { + var en = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + if (en.GetDefaultAudioEndpoint(0, 1, out var dev) != 0 || dev == null) return; + if (dev.GetId(out var boundId) != 0) boundId = null; + var acid = typeof(IAudioClient).GUID; + if (dev.Activate(ref acid, 23, IntPtr.Zero, out var aco) != 0 || aco is not IAudioClient ac) return; + if (ac.GetMixFormat(out IntPtr fmtPtr) != 0) return; + try + { + int channels = Marshal.ReadInt16(fmtPtr, 2); + int rate = Marshal.ReadInt32(fmtPtr, 4); + int bits = Marshal.ReadInt16(fmtPtr, 14); + if (bits != 32 || channels < 1 || rate < 8000) return; + + const uint LOOPBACK = 0x00020000; + if (ac.Initialize(0, LOOPBACK, 2_000_000, 0, fmtPtr, IntPtr.Zero) != 0) return; + var ccid = typeof(IAudioCaptureClient).GUID; + if (ac.GetService(ref ccid, out var cco) != 0 || cco is not IAudioCaptureClient cc) return; + if (ac.Start() != 0) return; + Available = true; + + var win = Hann(); + long nextFft = 0; + long nextDeviceCheck = Environment.TickCount64 + 1000; + while (Environment.TickCount64 <= _until) + { + + if (Environment.TickCount64 >= nextDeviceCheck) + { + nextDeviceCheck = Environment.TickCount64 + 1000; + if (boundId is { } b && DefaultRenderId() is { } cur && cur != b) break; + } + + while (cc.GetNextPacketSize(out uint pkt) == 0 && pkt > 0) + { + if (cc.GetBuffer(out IntPtr data, out uint frames, out uint flags, out _, out _) != 0) break; + bool silent = (flags & 2) != 0; + unsafe + { + float* p = (float*)data; + for (uint f = 0; f < frames; f++) + { + float l = 0, r = 0; + if (!silent) + { + l = p[f * channels]; + r = channels > 1 ? p[f * channels + 1] : l; + } + _ringL[_ringPos] = l; + _ringR[_ringPos] = r; + _ringPos = (_ringPos + 1) % _ringL.Length; + } + } + cc.ReleaseBuffer(frames); + } + + long now = Environment.TickCount64; + if (now >= nextFft) + { + nextFft = now + 25; + lock (_bands) ComputeBands(win, rate); + } + Thread.Sleep(5); + } + try { ac.Stop(); } catch { } + } + finally { Marshal.FreeCoTaskMem(fmtPtr); Available = false; } + } + + private static float[] Hann() + { + var w = new float[N]; + for (int i = 0; i < N; i++) w[i] = 0.5f - 0.5f * MathF.Cos(MathF.Tau * i / (N - 1)); + return w; + } + + private static readonly float[] _reL = new float[N], _imL = new float[N]; + private static readonly float[] _reR = new float[N], _imR = new float[N]; + + private static void ComputeBands(float[] win, int rate) + { + LoadFft(_ringL, _reL, _imL, win); + LoadFft(_ringR, _reR, _imR, win); + + Span target = stackalloc float[BandCount]; + int nb = Ch - 1; + double fMin = 55, fMax = Math.Min(12000, rate / 2.0 - 1); + for (int b = 0; b < nb; b++) + { + double lo = fMin * Math.Pow(fMax / fMin, b / (double)nb); + double hi = fMin * Math.Pow(fMax / fMin, (b + 1) / (double)nb); + float tilt = b * 3.2f; + target[nb - 1 - b] = BandValue(_reL, _imL, rate, lo, hi, tilt); + target[BandCount - nb + b] = BandValue(_reR, _imR, rate, lo, hi, tilt); + } + + { + int i0 = Math.Max(1, 150 * N / rate), i1 = Math.Max(i0 + 1, 3500 * N / rate); + double sum = 0; + for (int i = i0; i < i1 && i < N / 2; i++) + { + float mr = (_reL[i] + _reR[i]) / 2f, mi = (_imL[i] + _imR[i]) / 2f; + float sr = (_reL[i] - _reR[i]) / 2f, si = (_imL[i] - _imR[i]) / 2f; + double p = (mr * mr + mi * mi) - (sr * sr + si * si); + if (p > 0) sum += p; + } + double rms = Math.Sqrt(sum / Math.Max(1, i1 - i0)) / N; + float db = 20f * MathF.Log10((float)rms + 1e-9f); + target[BandCount / 2] = Math.Clamp((db + 62f) / 35f, 0f, 1f); + } + + float max = 0f; + for (int b = 0; b < BandCount; b++) if (b != BandCount / 2 && target[b] > max) max = target[b]; + if (max > 0.04f) + for (int b = 0; b < BandCount; b++) + if (b != BandCount / 2) + target[b] = MathF.Pow(target[b] / max, 1.6f) * (0.30f + 0.70f * max); + if (max <= 0.04f) + for (int b = 0; b < BandCount; b++) if (b != BandCount / 2) target[b] = 0f; + + for (int b = 0; b < BandCount; b++) + { + float v = target[b]; + + _bands[b] = v > _bands[b] ? _bands[b] + (v - _bands[b]) * 0.75f : _bands[b] + (v - _bands[b]) * 0.28f; + } + } + + private static void LoadFft(float[] ring, float[] re, float[] im, float[] win) + { + int start = _ringPos; + for (int i = 0; i < N; i++) + { + re[i] = ring[(start + ring.Length - N + i) % ring.Length] * win[i]; + im[i] = 0; + } + Fft(re, im); + } + + private static float BandValue(float[] re, float[] im, int rate, double lo, double hi, float tiltDb) + { + int i0 = Math.Max(1, (int)(lo * N / rate)), i1 = Math.Max(i0 + 1, (int)(hi * N / rate)); + double sum = 0; + for (int i = i0; i < i1 && i < N / 2; i++) sum += re[i] * re[i] + im[i] * im[i]; + double rms = Math.Sqrt(sum / Math.Max(1, i1 - i0)) / N; + float db = 20f * MathF.Log10((float)rms + 1e-9f); + return Math.Clamp((db + 55f + tiltDb) / 40f, 0f, 1f); + } + + private static void Fft(float[] re, float[] im) + { + int n = re.Length; + for (int i = 1, j = 0; i < n; i++) + { + int bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) j ^= bit; + j |= bit; + if (i < j) { (re[i], re[j]) = (re[j], re[i]); (im[i], im[j]) = (im[j], im[i]); } + } + for (int len = 2; len <= n; len <<= 1) + { + float ang = -MathF.Tau / len; + float wr = MathF.Cos(ang), wi = MathF.Sin(ang); + for (int i = 0; i < n; i += len) + { + float cr = 1, ci = 0; + for (int k = 0; k < len / 2; k++) + { + int a = i + k, b = i + k + len / 2; + float tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr; + re[b] = re[a] - tr; im[b] = im[a] - ti; + re[a] += tr; im[a] += ti; + (cr, ci) = (cr * wr - ci * wi, cr * wi + ci * wr); + } + } + } + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + private class MMDeviceEnumerator { } + + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice); + } + + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, uint clsCtx, IntPtr activationParams, + [MarshalAs(UnmanagedType.IUnknown)] out object iface); + + [PreserveSig] int OpenPropertyStore(uint access, out IntPtr store); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + } + + [ComImport, Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioClient + { + [PreserveSig] int Initialize(int shareMode, uint streamFlags, long bufferDuration, long periodicity, + IntPtr format, IntPtr audioSessionGuid); + [PreserveSig] int GetBufferSize(out uint size); + [PreserveSig] int GetStreamLatency(out long latency); + [PreserveSig] int GetCurrentPadding(out uint padding); + [PreserveSig] int IsFormatSupported(int shareMode, IntPtr format, out IntPtr closestMatch); + [PreserveSig] int GetMixFormat(out IntPtr format); + [PreserveSig] int GetDevicePeriod(out long defaultPeriod, out long minPeriod); + [PreserveSig] int Start(); + [PreserveSig] int Stop(); + [PreserveSig] int Reset(); + [PreserveSig] int SetEventHandle(IntPtr handle); + [PreserveSig] int GetService(ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object service); + } + + [ComImport, Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioCaptureClient + { + [PreserveSig] int GetBuffer(out IntPtr data, out uint frames, out uint flags, out long devPos, out long qpcPos); + [PreserveSig] int ReleaseBuffer(uint frames); + [PreserveSig] int GetNextPacketSize(out uint frames); + } +} diff --git a/src/Halo.App/Widgets/BrowserDownloads.cs b/src/Halo.App/Widgets/BrowserDownloads.cs new file mode 100644 index 0000000..f23900e --- /dev/null +++ b/src/Halo.App/Widgets/BrowserDownloads.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +namespace Halo.Widgets; + +internal static class BrowserDownloads +{ + + internal readonly record struct Row(string File, long Received, long Total, string Target); + + private const int OpenReadonly = 0x1, OpenUri = 0x40, RowResult = 100; + private const double CacheSeconds = 2.5; + private const double IdleMinutes = 30; + + private static readonly object _lock = new(); + private static List _cache = new(); + private static DateTime _cacheAt = DateTime.MinValue; + + private static IEnumerable ProfileRoots() + { + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + yield return Path.Combine(local, @"Google\Chrome\User Data"); + yield return Path.Combine(local, @"Microsoft\Edge\User Data"); + yield return Path.Combine(local, @"BraveSoftware\Brave-Browser\User Data"); + yield return Path.Combine(local, @"Vivaldi\User Data"); + yield return Path.Combine(roaming, @"Opera Software\Opera Stable"); + yield return Path.Combine(roaming, @"Opera Software\Opera GX Stable"); + } + + private static IEnumerable HistoryFiles() + { + foreach (var root in ProfileRoots()) + { + if (!Directory.Exists(root)) continue; + + string direct = Path.Combine(root, "History"); + if (File.Exists(direct)) yield return direct; + string[] subs; + try { subs = Directory.GetDirectories(root); } catch { continue; } + foreach (var sub in subs) + { + string name = Path.GetFileName(sub); + if (!name.Equals("Default", StringComparison.OrdinalIgnoreCase) && + !name.StartsWith("Profile ", StringComparison.OrdinalIgnoreCase)) continue; + string h = Path.Combine(sub, "History"); + if (File.Exists(h)) yield return h; + } + } + } + + public static List InProgress() + { + lock (_lock) + if ((DateTime.UtcNow - _cacheAt).TotalSeconds < CacheSeconds) return _cache; + + var rows = new List(); + foreach (var db in HistoryFiles()) + { + try { ReadInto(db, rows); } + catch { } + } + lock (_lock) { _cache = rows; _cacheAt = DateTime.UtcNow; } + return rows; + } + + public static long TotalFor(string partialPath) + { + if (string.IsNullOrEmpty(partialPath)) return 0; + string partial = Path.GetFileName(partialPath); + PartialFiles.IsPartial(partial, out string clean); + foreach (var r in InProgress()) + { + if (Same(Path.GetFileName(r.File), partial, clean)) return r.Total; + if (Same(Path.GetFileName(r.Target), partial, clean)) return r.Total; + } + return 0; + } + + private static bool Same(string candidate, string partial, string clean) + { + if (candidate.Length == 0) return false; + if (candidate.Equals(partial, StringComparison.OrdinalIgnoreCase)) return true; + if (clean.Length > 0 && candidate.Equals(clean, StringComparison.OrdinalIgnoreCase)) return true; + + return clean.Length > 0 && StripCopySuffix(clean) is { Length: > 0 } bare + && candidate.Equals(bare, StringComparison.OrdinalIgnoreCase); + } + + internal static string StripCopySuffix(string fileName) + { + string stem = Path.GetFileNameWithoutExtension(fileName), ext = Path.GetExtension(fileName); + int open = stem.LastIndexOf(" (", StringComparison.Ordinal); + if (open <= 0 || !stem.EndsWith(")", StringComparison.Ordinal)) return fileName; + string inner = stem.Substring(open + 2, stem.Length - open - 3); + if (inner.Length == 0 || inner.Length > 3) return fileName; + foreach (char c in inner) if (c is < '0' or > '9') return fileName; + return stem.Substring(0, open) + ext; + } + + public static string? NameFor(string partialPath) + { + if (string.IsNullOrEmpty(partialPath)) return null; + string target = Path.GetFileName(partialPath); + foreach (var r in InProgress()) + if (Path.GetFileName(r.File).Equals(target, StringComparison.OrdinalIgnoreCase)) + { + string n = Path.GetFileName(r.Target); + if (n.Length == 0) return null; + return PartialFiles.IsPartial(n, out string clean) && clean.Length > 0 ? clean : n; + } + return null; + } + + private static void ReadInto(string dbPath, List rows) + { + string wal = dbPath + "-wal"; + + try + { + var recent = File.Exists(wal) ? File.GetLastWriteTimeUtc(wal) : File.GetLastWriteTimeUtc(dbPath); + if ((DateTime.UtcNow - recent).TotalMinutes > IdleMinutes) return; + } + catch { return; } + + string tmpDir = Path.Combine(Path.GetTempPath(), "halo-dlsnap"); + string snap = Path.Combine(tmpDir, "h" + Math.Abs(dbPath.GetHashCode()) + ".db"); + try + { + Directory.CreateDirectory(tmpDir); + File.Copy(dbPath, snap, overwrite: true); + if (File.Exists(wal)) File.Copy(wal, snap + "-wal", overwrite: true); + + string uri = "file:///" + snap.Replace('\\', '/').Replace(" ", "%20"); + if (sqlite3_open_v2(Utf8(uri), out IntPtr db, OpenReadonly | OpenUri, IntPtr.Zero) != 0) + { sqlite3_close(db); return; } + try + { + + const string sql = @"SELECT target_path, current_path, received_bytes, total_bytes + FROM downloads WHERE total_bytes > 0 ORDER BY id DESC LIMIT 40"; + if (sqlite3_prepare_v2(db, Utf8(sql), -1, out IntPtr st, IntPtr.Zero) != 0) return; + try + { + while (sqlite3_step(st) == RowResult) + { + string target = Str(sqlite3_column_text(st, 0)); + string current = Str(sqlite3_column_text(st, 1)); + long got = sqlite3_column_int64(st, 2), total = sqlite3_column_int64(st, 3); + + rows.Add(new Row(current.Length > 0 ? current : target, got, total, target)); + } + } + finally { sqlite3_finalize(st); } + } + finally { sqlite3_close(db); } + } + finally + { + try { File.Delete(snap); File.Delete(snap + "-wal"); } catch { } + } + } + + private static byte[] Utf8(string s) + { + var b = new byte[System.Text.Encoding.UTF8.GetByteCount(s) + 1]; + System.Text.Encoding.UTF8.GetBytes(s, 0, s.Length, b, 0); + return b; + } + + private static string Str(IntPtr p) => p == IntPtr.Zero ? "" : Marshal.PtrToStringUTF8(p) ?? ""; + + private const string Sqlite = "winsqlite3.dll"; + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_open_v2(byte[] filename, out IntPtr db, int flags, IntPtr vfs); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int nByte, out IntPtr stmt, IntPtr tail); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_step(IntPtr stmt); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr sqlite3_column_text(IntPtr stmt, int col); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern long sqlite3_column_int64(IntPtr stmt, int col); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_finalize(IntPtr stmt); + [DllImport(Sqlite, CallingConvention = CallingConvention.Cdecl)] + private static extern int sqlite3_close(IntPtr db); +} diff --git a/src/Halo.App/Widgets/BtWidget.cs b/src/Halo.App/Widgets/BtWidget.cs new file mode 100644 index 0000000..a184c2b --- /dev/null +++ b/src/Halo.App/Widgets/BtWidget.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; + +namespace Halo.Widgets; + +internal sealed class BtWidget : IWidget +{ + private const int HoldMs = 6000; + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Track = Color.FromArgb(46, 255, 255, 255); + private static readonly FontFamily Fluent = new("Segoe Fluent Icons"); + + private readonly object _lock = new(); + private string _name = ""; + private int _pct; + private int _glyph = 0xE702; + private long _until; + private int _version; + private float _fillShown = -1f; + + public void Show(string name, int pct) + { + lock (_lock) + { + _name = name; + _pct = Math.Clamp(pct, 0, 100); + _glyph = GlyphFor(name); + _fillShown = 0f; + _until = Environment.TickCount64 + HoldMs; + _version++; + } + } + + public bool IsActive { get { lock (_lock) return Environment.TickCount64 < _until; } } + public int Version { get { lock (_lock) return _version; } } + public bool Animating => IsActive; + + public string Icon => ((char)0xE702).ToString(); + + private static int GlyphFor(string name) + { + string n = name.ToLowerInvariant(); + if (n.Contains("airpod") || n.Contains("buds") || n.Contains("headphone") || n.Contains("headset") + || n.Contains("hands-free") || n.Contains(" hf") || n.StartsWith("wh-") || n.StartsWith("wf-") + || n.Contains("earbud") || n.Contains("pods")) return 0xE7F6; + if (n.Contains("controller") || n.Contains("dualsense") || n.Contains("dualshock") + || n.Contains("xbox") || n.Contains("gamepad")) return 0xE7FC; + if (n.Contains("keyboard")) return 0xE765; + if (n.Contains("mouse")) return 0xE962; + if (n.Contains("speaker") || n.Contains("soundbar") || n.Contains("jbl") + || n.Contains("boom") || n.Contains("sound")) return 0xE7F5; + if (n.Contains("watch") || n.Contains("band")) return 0xEC92; + return 0xE8EA; + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + int pct; int glyph; + lock (_lock) { pct = _pct; glyph = _glyph; } + + if (h < 16) return; + + g.SmoothingMode = SmoothingMode.AntiAlias; + + float target = pct / 100f; + _fillShown = _fillShown < 0 ? target : _fillShown + (target - _fillShown) * 0.16f; + if (Math.Abs(target - _fillShown) < 0.004f) _fillShown = target; + float fill = Math.Clamp(_fillShown, 0f, 1f); + Color ringCol = Charge(fill); + + float sz = h - 12f, x = 9f, cy = h / 2f, cx = x + sz / 2f; + Fx.Glow(g, w, h, fade, cx, cy, w * 0.6f, h * 2.0f, 34, ringCol); + + float ir = sz / 2f - 4.5f; + using (var disc = new SolidBrush(Mul(Color.FromArgb(20, 255, 255, 255), fade))) + g.FillEllipse(disc, cx - ir, cy - ir, ir * 2, ir * 2); + DrawGlyph(g, new RectangleF(cx - ir, cy - ir, ir * 2, ir * 2), glyph, fade, White); + + float rr = sz / 2f - 1f; + using (var tp = new Pen(Mul(Track, fade), 2.4f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawArc(tp, cx - rr, cy - rr, rr * 2, rr * 2, 0, 360); + if (fill > 0.001f) + using (var fp = new Pen(Mul(ringCol, fade), 2.8f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawArc(fp, cx - rr, cy - rr, rr * 2, rr * 2, -90, 360f * fill); + + using var pf = new Font("Segoe UI Semibold", h * 0.42f, GraphicsUnit.Pixel); + using var pb = new SolidBrush(Mul(White, fade)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, LineAlignment = StringAlignment.Center }; + g.DrawString($"{pct}%", pf, pb, new RectangleF(cx + sz, 0, w - (cx + sz) - 14, h), sf); + } + + private static Color Charge(float fill) + => Fx.HsvToRgb(Math.Clamp(fill, 0f, 1f) * 120f, 0.68f, 0.96f); + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) return; + int pct, glyph; string name; + lock (_lock) { pct = _pct; glyph = _glyph; name = _name; } + g.SmoothingMode = SmoothingMode.AntiAlias; + float fill = pct / 100f; + Color ringCol = Charge(fill); + + float cx = 70, cy = h / 2f, rr = 44; + Fx.Glow(g, w, h, fade, cx, cy, w * 0.7f, h * 1.2f, 36, ringCol); + using (var disc = new SolidBrush(Mul(Color.FromArgb(20, 255, 255, 255), fade))) + g.FillEllipse(disc, cx - rr + 8, cy - rr + 8, (rr - 8) * 2, (rr - 8) * 2); + DrawGlyph(g, new RectangleF(cx - rr + 8, cy - rr + 8, (rr - 8) * 2, (rr - 8) * 2), glyph, fade, White); + using (var tp = new Pen(Mul(Track, fade), 4f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawArc(tp, cx - rr, cy - rr, rr * 2, rr * 2, 0, 360); + using (var fp = new Pen(Mul(ringCol, fade), 4.6f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawArc(fp, cx - rr, cy - rr, rr * 2, rr * 2, -90, 360f * fill); + + float tx = cx + rr + 22; + using var nf = new Font("Segoe UI Semibold", 22f, GraphicsUnit.Pixel); + using var bf = new Font("Segoe UI", 15f, GraphicsUnit.Pixel); + using (var nb = new SolidBrush(Mul(White, fade))) + g.DrawString(name, nf, nb, tx, cy - 26); + using (var bb = new SolidBrush(Mul(Dim, fade))) + g.DrawString($"{pct}% battery", bf, bb, tx, cy + 4); + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + => Array.Empty<(RectangleF, Action)>(); + + private static readonly Dictionary _glyphCache = new(); + private static Bitmap GlyphBitmap(int cp) + { + lock (_glyphCache) + { + if (_glyphCache.TryGetValue(cp, out var cached)) return cached; + var b = RenderTight(cp); + _glyphCache[cp] = b; + return b; + } + } + + private static Bitmap RenderTight(int cp) + { + const int N = 128; + var full = new Bitmap(N, N, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(full)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + using var f = new Font(Fluent, N * 0.7f, GraphicsUnit.Pixel); + using var br = new SolidBrush(Color.White); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + sf.FormatFlags |= StringFormatFlags.NoClip; + g.DrawString(((char)cp).ToString(), f, br, new RectangleF(0, 0, N, N), sf); + } + var ink = InkBounds(full); + if (ink.Width <= 0 || ink.Height <= 0) return full; + var tight = full.Clone(ink, PixelFormat.Format32bppArgb); + full.Dispose(); + return tight; + } + + private static Rectangle InkBounds(Bitmap b) + { + var data = b.LockBits(new Rectangle(0, 0, b.Width, b.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + try + { + int stride = data.Stride; + var buf = new byte[stride * b.Height]; + System.Runtime.InteropServices.Marshal.Copy(data.Scan0, buf, 0, buf.Length); + int minX = b.Width, minY = b.Height, maxX = -1, maxY = -1; + for (int y = 0; y < b.Height; y++) + for (int x = 0; x < b.Width; x++) + if (buf[y * stride + x * 4 + 3] > 16) + { + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + return maxX < minX ? Rectangle.Empty : Rectangle.FromLTRB(minX, minY, maxX + 1, maxY + 1); + } + finally { b.UnlockBits(data); } + } + + private static void DrawGlyph(Graphics g, RectangleF r, int cp, float fade, Color tint) + { + var gb = GlyphBitmap(cp); + float target = r.Height * 0.58f; + float scale = target / Math.Max(gb.Width, gb.Height); + float dw = gb.Width * scale, dh = gb.Height * scale; + var dst = new RectangleF(r.X + (r.Width - dw) / 2f, r.Y + (r.Height - dh) / 2f, dw, dh); + using var ia = new ImageAttributes(); + ia.SetColorMatrix(new ColorMatrix(new[] + { + new float[] { 0, 0, 0, 0, 0 }, + new float[] { 0, 0, 0, 0, 0 }, + new float[] { 0, 0, 0, 0, 0 }, + new float[] { 0, 0, 0, fade * 0.95f, 0 }, + new float[] { tint.R / 255f, tint.G / 255f, tint.B / 255f, 0, 1 }, + })); + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.DrawImage(gb, new[] { dst.Location, new PointF(dst.Right, dst.Y), new PointF(dst.X, dst.Bottom) }, + new RectangleF(0, 0, gb.Width, gb.Height), GraphicsUnit.Pixel, ia); + } + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); +} diff --git a/src/Halo.App/Widgets/ChromiumProgress.cs b/src/Halo.App/Widgets/ChromiumProgress.cs new file mode 100644 index 0000000..50fa0e6 --- /dev/null +++ b/src/Halo.App/Widgets/ChromiumProgress.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Halo.Widgets; + +internal static class ChromiumProgress +{ + internal readonly record struct Entry(string Name, long Received, long Total, string CurrentPath); + + private readonly record struct Row(string Name, long Received, long Total, long State, string CurrentPath); + + private const double CacheSeconds = 2.0; + private static readonly object _lock = new(); + private static Entry[] _cache = Array.Empty(); + private static DateTime _cacheAt = DateTime.MinValue; + + public static Entry[] Live() + { + lock (_lock) + if ((DateTime.UtcNow - _cacheAt).TotalSeconds < CacheSeconds) return _cache; + + var live = new Dictionary(StringComparer.Ordinal); + foreach (var log in Logs()) + { + try { ReadLog(log, live); } + catch { } + } + var found = new List(); + foreach (var r in live.Values) + if (r.State == 0 && r.Total > 0 && r.Name.Length > 0) + found.Add(new Entry(r.Name, r.Received, r.Total, r.CurrentPath)); + var arr = found.ToArray(); + lock (_lock) { _cache = arr; _cacheAt = DateTime.UtcNow; } + return arr; + } + + public static Entry? For(string? partialPath, long fileBytes) + { + var live = Live(); + if (live.Length == 0) return null; + + if (!string.IsNullOrEmpty(partialPath)) + foreach (var e in live) + if (e.CurrentPath.Length > 0 && + string.Equals(e.CurrentPath, partialPath, StringComparison.OrdinalIgnoreCase)) + return e; + + if (live.Length == 1) return live[0].Total > 0 ? live[0] : null; + + Entry? best = null; + long bestGap = long.MaxValue; + foreach (var e in live) + { + if (e.Total <= 0) continue; + long gap = Math.Abs(e.Received - fileBytes); + if (gap < bestGap) { bestGap = gap; best = e; } + } + return best; + } + + private static IEnumerable Logs() + { + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var roots = new[] + { + Path.Combine(local, @"Microsoft\Edge\User Data"), + Path.Combine(local, @"Google\Chrome\User Data"), + Path.Combine(local, @"BraveSoftware\Brave-Browser\User Data"), + Path.Combine(local, @"Vivaldi\User Data"), + Path.Combine(roaming, @"Opera Software\Opera Stable"), + }; + foreach (var root in roots) + { + if (!Directory.Exists(root)) continue; + string[] subs; + try { subs = Directory.GetDirectories(root); } catch { continue; } + foreach (var sub in subs) + { + string name = Path.GetFileName(sub); + if (!name.Equals("Default", StringComparison.OrdinalIgnoreCase) && + !name.StartsWith("Profile ", StringComparison.OrdinalIgnoreCase)) continue; + string db = Path.Combine(sub, "shared_proto_db"); + if (!Directory.Exists(db)) continue; + string[] logs; + try { logs = Directory.GetFiles(db, "*.log"); } catch { continue; } + foreach (var l in logs) yield return l; + } + } + } + + private const int Block = 32768; + + private static void ReadLog(string path, Dictionary into) + { + byte[] data; + try + { + + using var src = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var ms = new MemoryStream(); + src.CopyTo(ms); + data = ms.ToArray(); + } + catch { return; } + + Blocks(data, (key, b, off, len) => Parse(b, off, len, into, key), key => into.Remove(key)); + } + + private static void Blocks(byte[] data, Action onPut, Action onDelete) + { + var frag = new List(); + int pos = 0; + while (pos + 7 <= data.Length) + { + int inBlock = pos % Block; + if (Block - inBlock < 7) { pos += Block - inBlock; continue; } + int len = data[pos + 4] | (data[pos + 5] << 8); + byte type = data[pos + 6]; + pos += 7; + if (len < 0 || pos + len > data.Length) break; + if (type == 0 && len == 0) { pos += Block - (pos % Block); continue; } + + switch (type) + { + case 1: Batch(data, pos, len, onPut, onDelete); break; + case 2: frag.Clear(); Add(frag, data, pos, len); break; + case 3: Add(frag, data, pos, len); break; + case 4: + Add(frag, data, pos, len); + var whole = frag.ToArray(); + Batch(whole, 0, whole.Length, onPut, onDelete); + frag.Clear(); + break; + } + pos += len; + } + } + + private static void Add(List to, byte[] src, int off, int len) + { + for (int i = 0; i < len; i++) to.Add(src[off + i]); + } + + private static void Batch(byte[] b, int off, int len, + Action onPut, Action onDelete) + { + int i = off + 12, end = off + len; + while (i < end) + { + byte tag = b[i++]; + if (!Len(b, ref i, end, out int kl)) return; + int kOff = i; i += kl; + if (i > end) return; + if (tag != 1) { if (kl >= 12) onDelete(Encoding.ASCII.GetString(b, kOff, kl)); continue; } + if (!Len(b, ref i, end, out int vl)) return; + int vOff = i; i += vl; + if (i > end) return; + + if (kl < 12 || Encoding.ASCII.GetString(b, kOff, 11) != "21_download") continue; + string key = Encoding.ASCII.GetString(b, kOff, kl); + if (vl == 0) { onDelete(key); continue; } + onPut(key, b, vOff, vl); + } + } + + private static bool Len(byte[] b, ref int i, int end, out int len) + { + len = 0; + if (!Varint(b, ref i, end, out ulong v) || v > (ulong)(end - i)) return false; + len = (int)v; + return true; + } + + private static void Parse(byte[] b, int off, int len, Dictionary into, string key) + { + if (!Sub(b, off, len, 1, out int iOff, out int iLen)) return; + if (!Sub(b, iOff, iLen, 4, out int pOff, out int pLen)) return; + + string url = "", current = "", target = ""; long total = 0, recv = 0, state = -1; + int i = pOff, end = pOff + pLen; + while (i < end) + { + if (!Varint(b, ref i, end, out ulong tag)) return; + int field = (int)(tag >> 3), wire = (int)(tag & 7); + if (wire == 0) + { + if (!Varint(b, ref i, end, out ulong v)) return; + if (field == 10) total = (long)v; + else if (field == 15) recv = (long)v; + else if (field == 21) state = (long)v; + } + else if (wire == 2) + { + if (!Len(b, ref i, end, out int l)) return; + if (field == 1) url = Encoding.UTF8.GetString(b, i, l); + else if (field == 13) current = PickledPath(b, i, l); + else if (field == 14) target = PickledPath(b, i, l); + i += l; + } + else if (wire == 5) i += 4; + else if (wire == 1) i += 8; + else return; + } + + string name = ""; + try { if (target.Length > 0) name = Path.GetFileName(target); } catch { } + if (name.Length == 0) name = NameFromUrl(url); + + into[key] = new Row(name, recv, total, state, current); + } + + private static string PickledPath(byte[] b, int off, int len) + { + try + { + if (len < 8) return ""; + int chars = b[off + 4] | (b[off + 5] << 8) | (b[off + 6] << 16) | (b[off + 7] << 24); + if (chars <= 0 || 8 + chars * 2 > len) return ""; + return Encoding.Unicode.GetString(b, off + 8, chars * 2); + } + catch { return ""; } + } + + internal static string DumpFields() + { + var sb = new StringBuilder(); + foreach (var log in Logs()) + { + byte[] data; + try + { + using var src = new FileStream(log, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var ms = new MemoryStream(); + src.CopyTo(ms); + data = ms.ToArray(); + } + catch { continue; } + + var recs = new Dictionary(StringComparer.Ordinal); + Blocks(data, + (key, b, off, len) => + { + var copy = new byte[len]; + Array.Copy(b, off, copy, 0, len); + recs[key] = copy; + }, + key => recs.Remove(key)); + foreach (var kv in recs) + { + sb.AppendLine($"-- {kv.Key}"); + if (!Sub(kv.Value, 0, kv.Value.Length, 1, out int iOff, out int iLen)) continue; + Fields(sb, kv.Value, iOff, iLen, "f1"); + if (Sub(kv.Value, iOff, iLen, 4, out int pOff, out int pLen)) + Fields(sb, kv.Value, pOff, pLen, "f1.f4"); + } + } + return sb.ToString(); + } + + private static void Fields(StringBuilder sb, byte[] b, int off, int len, string prefix) + { + int i = off, end = off + len; + while (i < end) + { + if (!Varint(b, ref i, end, out ulong tag)) return; + int field = (int)(tag >> 3), wire = (int)(tag & 7); + if (wire == 0) + { + if (!Varint(b, ref i, end, out ulong v)) return; + sb.AppendLine($" {prefix}.{field} varint = {v}"); + } + else if (wire == 2) + { + if (!Len(b, ref i, end, out int l)) return; + string s = Encoding.UTF8.GetString(b, i, l); + bool text = true; + foreach (char c in s) if (char.IsControl(c) && c != '\t') { text = false; break; } + + if (!text) + { + string p = PickledPath(b, i, l); + if (p.Length > 0) { s = "FilePath " + p; text = true; } + } + if (!text) + { + var hex = new StringBuilder(); + for (int k = 0; k < Math.Min(l, 48); k++) hex.Append(b[i + k].ToString("x2")).Append(' '); + s = " " + hex; + } + sb.AppendLine($" {prefix}.{field} bytes[{l}] = {s}"); + i += l; + } + else if (wire == 5) i += 4; + else if (wire == 1) i += 8; + else return; + } + } + + private static bool Sub(byte[] b, int off, int len, int field, out int subOff, out int subLen) + { + subOff = subLen = 0; + int i = off, end = off + len; + while (i < end) + { + if (!Varint(b, ref i, end, out ulong key)) return false; + int f = (int)(key >> 3), wire = (int)(key & 7); + if (wire == 2) + { + if (!Len(b, ref i, end, out int l)) return false; + if (f == field) { subOff = i; subLen = l; return true; } + i += l; + } + else if (wire == 0) { if (!Varint(b, ref i, end, out _)) return false; } + else if (wire == 5) i += 4; + else if (wire == 1) i += 8; + else return false; + } + return false; + } + + private static bool Varint(byte[] b, ref int i, int end, out ulong value) + { + value = 0; + int shift = 0; + while (i < end && shift <= 63) + { + byte x = b[i++]; + value |= (ulong)(x & 0x7f) << shift; + if ((x & 0x80) == 0) return true; + shift += 7; + } + return false; + } + + private static string NameFromUrl(string url) + { + try + { + if (url.Length == 0 || url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return ""; + int q = url.IndexOfAny(new[] { '?', '#' }); + string path = q >= 0 ? url.Substring(0, q) : url; + int slash = path.LastIndexOf('/'); + string name = slash >= 0 ? path.Substring(slash + 1) : path; + name = Uri.UnescapeDataString(name); + foreach (char c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); + return name.Length > 80 ? name.Substring(0, 80) : name; + } + catch { return ""; } + } +} diff --git a/src/Halo.App/Widgets/ClaudeCodeWidget.cs b/src/Halo.App/Widgets/ClaudeCodeWidget.cs new file mode 100644 index 0000000..f148f10 --- /dev/null +++ b/src/Halo.App/Widgets/ClaudeCodeWidget.cs @@ -0,0 +1,893 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using Halo.Agents; +using Halo.ClaudeCode; + +namespace Halo.Widgets; + +internal sealed class ClaudeCodeWidget : IWidget +{ + private static readonly Color Blue = Color.FromArgb(91, 157, 255); + private static readonly Color Green = Color.FromArgb(62, 207, 92); + private static readonly Color Amber = Color.FromArgb(255, 176, 32); + private static readonly Color Red = Color.FromArgb(229, 72, 77); + private static readonly Color Mint = Color.FromArgb(82, 224, 163); + private const float MinVerbPx = 12.5f; + private static readonly Color Track = Color.FromArgb(38, 255, 255, 255); + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + + private readonly StatusStore _store; + private readonly int _slot; + private readonly Action _cancel; + + public ClaudeCodeWidget(StatusStore store, int slot, Action cancel) + { + _store = store; + _slot = slot; + _cancel = cancel; + } + + private static readonly Bitmap? ClaudeIcon = LoadIcon(); + internal static Bitmap? PlainIcon => ClaudeIcon; + + private static readonly Color Accent = Fx.AccentOf(ClaudeIcon) is var a && a != Fx.White + ? a : Color.FromArgb(217, 119, 87); + + public string Icon => "\uE756"; + + private Bitmap? _badged; + + public Bitmap? IconImage + { + get + { + if (ClaudeIcon is null) return null; + if (_store.LiveSessions() < 2) return ClaudeIcon; + return _badged ??= Fx.Badge(ClaudeIcon, (char)('1' + _slot)); + } + } + + public bool IsActive => Live is not null; + private CcStatus? Live => _store.SessionLive(_slot); + public Color? Ring => Live is { } st ? RingColor(st) : null; + + public float RingProgress + => Live is null || (Limits.FiveHour < 0 && Limits.Week < 0) ? -1f : UsageFrac(); + public int Version => _store.Version + NetMon.Version + CompactProgress.Version; + public AgentNotice AgentNotice => Live is { } status + ? new AgentNotice(Shown(status), ParseTime(status.CompactedAt), status.Message) + : AgentNotice.None; + public IEnumerable OwnerPids => Live is { } st ? new[] { st.Pid, st.ConsolePid } : Array.Empty(); + + public bool Animating => _appear < 1f || Compacting(Live) + || (_wasOpen && (WidgetInput.Over || RingsSettling)); + + private string _shownKey = ""; + private float _appear = 1f; + + private readonly float[] _ringLift = new float[3]; + private long _ringTick; + private bool RingsSettling + { + get { foreach (var v in _ringLift) if (v > 0.01f) return true; return false; } + } + + private static Bitmap? LoadIcon() + { + try + { + using var s = typeof(ClaudeCodeWidget).Assembly.GetManifestResourceStream("Halo.Assets.claude.png"); + return s != null ? new Bitmap(s) : null; + } + catch { return null; } + } + + private bool CanCancel => Live is { Pid: > 0 } st && Shown(st) == "working"; + + private bool _wasOpen; + + public void DrawContent(Graphics g, int w, int h, float fade) + { + bool open = fade > 0.01f; + if (open && !_wasOpen) Limits.OnPanelOpen(); + _wasOpen = open; + if (open) + { + NetMon.Poke(); + Fx.Glow(g, w, h, fade, w * 0.16f, h * 0.35f, w * 0.85f, h * 1.2f, 30, Accent); + DrawExpanded(g, w, h, fade, Live); + } + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + var st = Live; + float sz = (h - 16f) * 0.82f, x = 13, y = (h - sz) / 2f; + g.SmoothingMode = SmoothingMode.AntiAlias; + + if (!Compacting(st)) Fx.PillBar(g, w, h, fade, UsageFrac(), Accent, 0.3f); + Fx.Glow(g, w, h, fade, x + sz / 2f, h / 2f, w * 0.7f, h * 2.2f, 26, Accent); + if (Compacting(st)) + { + float pulse = 0.5f - 0.5f * MathF.Cos(Environment.TickCount % 2400 / 2400f * MathF.Tau); + using var pb = new SolidBrush(Mul(Blue, fade * (0.05f + 0.11f * pulse))); + using var pp = Fx.PillPath(w, h, h / 2f); + g.FillPath(pb, pp); + } + + using (var pen = new Pen(Mul(RingColor(st), fade * 0.9f), 1.9f)) + g.DrawEllipse(pen, x - 2.5f, y - 2.5f, sz + 5f, sz + 5f); + if (ClaudeIcon != null) DrawIcon(g, ClaudeIcon, x, y, sz, fade, sz / 2f); + else + using (var db = new SolidBrush(Mul(RingColor(st), fade))) + g.FillEllipse(db, x, y, sz, sz); + + string el0 = LimitHit ? LimitReset() : Elapsed(st); + if (Compacting(st) && !LimitHit && CompactPct(st!) is { Length: > 0 } done) + el0 = el0.Length > 0 ? done + " · " + Coarse(el0) : done; + float textX0 = x + sz + 11; + if (st?.State == "waiting_input") textX0 += 16; + using var elFont = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + float elW0 = el0.Length > 0 + ? g.MeasureString(el0, elFont, int.MaxValue, StringFormat.GenericTypographic).Width : 0; + float avail0 = (w - 14) - textX0 - (elW0 > 0 ? elW0 + 10 : 0); + + int fit = fade > 0.99f ? Fx.FitChars(g, avail0, MinVerbPx) : 0; + var mood = Mood(st) with { MaxChars = fit >= 8 ? fit : 0 }; + string verb = OutageText() ?? (LimitHit ? "outta juice :(" : Shown(st) switch + { + "working" => ToolVerb(Glow(st).Tool, mood), + "compacting" when Compacting(st) => Moods.Line("compacting", mood), + "waiting_input" => "your move ;)", + _ => IdleMood(st, mood), + }); + string el = el0; + if (verb != _shownKey) { _shownKey = verb; _appear = 0f; } + else if (_appear < 1f) _appear = Math.Min(1f, _appear + 0.1f); + float e = 1f - MathF.Pow(1f - _appear, 3); + bool busy = Shown(st) == "working" || Compacting(st) || LimitHit; + bool centred = !busy && st?.State != "waiting_input"; + + float textX = x + sz + 11; + if (st?.State == "waiting_input") textX += 16; + using var tf2 = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + float elW = el.Length > 0 ? g.MeasureString(el, tf2, int.MaxValue, StringFormat.GenericTypographic).Width : 0; + float avail = (w - 14) - textX - (elW > 0 ? elW + 10 : 0); + + float px = 15f; + using (var fm = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel)) + { + var m0 = g.MeasureString(verb, fm, int.MaxValue, StringFormat.GenericTypographic); + + if (m0.Width > avail && m0.Width > 0) px = Math.Max(MinVerbPx, px * avail / m0.Width); + } + using var f = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade * e)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { + Alignment = centred ? StringAlignment.Center : StringAlignment.Near, + LineAlignment = StringAlignment.Center, + FormatFlags = StringFormatFlags.NoWrap, + + Trimming = StringTrimming.EllipsisCharacter, + }; + + float originX = textX - 16f * (1f - e); + float rightEdge = textX + avail; + var clip = g.Clip; + g.SetClip(new RectangleF(x + sz + 2, 0, rightEdge - (x + sz + 2), h)); + + float zoneW = (centred ? rightEdge - 34f : rightEdge) - originX; + + g.DrawString(verb, f, b, new RectangleF(originX, -Fx.CenterLift(f), zoneW, h), sf); + g.Clip = clip; + + if (elW > 0) + using (var eb = new SolidBrush(Mul(Dim, fade * e))) + using (var esf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }) + g.DrawString(el, tf2, eb, new RectangleF(w - 14 - elW - 4, -Fx.CenterLift(tf2), elW + 4, h), esf); + + } + + private static string? _cancelledCompactKey; + + public static void MarkCompactCancelled(string? startedAt) => _cancelledCompactKey = startedAt; + + private static string? _cancelledTurnKey; + + public static void MarkTurnCancelled(string? startedAt) => _cancelledTurnKey = startedAt; + + internal const int SettleAfterSeconds = 180; + + internal static bool TurnOver(CcStatus? st, DateTimeOffset now) + { + if (st is not { State: "working" }) return false; + if (st.StartedAt is { Length: > 0 } && st.StartedAt == _cancelledTurnKey) return true; + if (!string.IsNullOrEmpty(st.CurrentTool)) return false; + return ParseTime(st.UpdatedAt) is { } u && now - u > TimeSpan.FromSeconds(SettleAfterSeconds); + } + + private static string? Shown(CcStatus? st) => + TurnOver(st, DateTimeOffset.UtcNow) ? "idle" : st?.State; + + internal static bool Compacting(CcStatus? st) => + st?.State == "compacting" && st.StartedAt != _cancelledCompactKey + && ParseTime(st.StartedAt) is { } t + && DateTimeOffset.UtcNow - t < TimeSpan.FromMinutes(3); + + internal static string CompactPct(CcStatus st) + => CompactProgress.Caption(); + + internal static string Coarse(string elapsed) + { + int m = elapsed.IndexOf('m'); + return m > 0 ? elapsed[..(m + 1)] : elapsed; + } + + private static DateTimeOffset? ParseTime(string? s) => + DateTimeOffset.TryParse(s, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t) + ? t : null; + + private static void DrawIcon(Graphics g, Bitmap img, float x, float y, float size, float fade, float radius) + { + using var path = Rounded(new RectangleF(x, y, size, size), radius); + int s = Math.Max(1, (int)Math.Ceiling(size)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = fade }); + int side = Math.Min(img.Width, img.Height); + sg.DrawImage(img, new Rectangle(0, 0, s, s), (img.Width - side) / 2, (img.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(x, y); + g.FillPath(tb, path); + } + + internal static float ContextWarnAt + => Halo.Settings.SettingsStore.Percent("alert.contextAt", 80) / 100f; + + internal static int ContextBand(float frac) + => frac >= ContextWarnAt ? 2 : frac >= ContextWarnAt - 0.15f ? 1 : 0; + + internal static Color ContextColour(double frac) + => frac < 0 ? Blue : ContextBand((float)frac) switch { 2 => Red, 1 => Amber, _ => Blue }; + + internal (string? id, float frac) ContextState() + { + var st = Live; + if (st?.Session is not { ContextMax: > 0 } ses) return (null, -1f); + var id = st.Pid + ":" + st.StartedAt; + return (id, (float)Math.Clamp((double)ses.ContextUsed / ses.ContextMax, 0, 1)); + } + + private const int Pad = 22; + private const float ColR = 356f, RightEdge = 538f; + private const float RingCx = 84f, RingCy = 132f, RingOuter = 52f, RingBand = 8f, RingStep = 16f; + private const float KeyX = 178f, KeyValX = 268f; + private const float Row0 = 96f, RowPitch = 42f; + + private static float TextTop(Font f, float baseline) + => MathF.Round(baseline - f.FontFamily.GetCellAscent(f.Style) / (float)f.FontFamily.GetEmHeight(f.Style) * f.Size); + + private static void Text(Graphics g, string t, Font f, Brush b, float x, float baseline) + => g.DrawString(t, f, b, MathF.Round(x), TextTop(f, baseline), StringFormat.GenericTypographic); + + private static readonly StringFormat AdvanceFmt = + new(StringFormat.GenericTypographic) { FormatFlags = StringFormatFlags.MeasureTrailingSpaces }; + + private static float Advance(Graphics g, string t, Font f) + => t.Length == 0 ? 0f : g.MeasureString(t, f, System.Drawing.Point.Empty, AdvanceFmt).Width; + + private static void TextClipped(Graphics g, string t, Font f, Brush b, float x, float baseline, float w) + { + using var sf = new StringFormat(StringFormat.GenericTypographic) + { FormatFlags = StringFormatFlags.NoWrap, Trimming = StringTrimming.EllipsisCharacter }; + g.DrawString(t, f, b, new RectangleF(MathF.Round(x), TextTop(f, baseline), w, f.Size * 1.6f), sf); + } + + private void DrawExpanded(Graphics g, int w, int h, float a, CcStatus? st) + { + using var title = new Font("Segoe UI Semibold", 22f, GraphicsUnit.Pixel); + using var line = new Font("Segoe UI", 14f, GraphicsUnit.Pixel); + using var keyCap = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var keyVal = new Font("Segoe UI Semibold", 16f, GraphicsUnit.Pixel); + + using var keySub = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + + g.SmoothingMode = SmoothingMode.AntiAlias; + var state = RingColor(st); + + DrawCancel(g, w, h, a, state); + using (var tb = new SolidBrush(Mul(White, a))) + Text(g, "Claude Code", title, tb, 84, 40); + + if (st?.State == "waiting_input" && !string.IsNullOrEmpty(st.Message)) + using (var ab = new SolidBrush(Mul(Amber, a))) + TextClipped(g, st.Message!, line, ab, 84, 62, ColR - 92); + + double ctxFrac = st?.Session is { ContextMax: > 0 } ? ContextFrac(st) : -1; + + var ctxCol = ContextColour(ctxFrac); + var rings = new (float frac, Color col)[] + { + (Limits.FiveHour, Limits.FiveHour >= 0 ? UsageColor(Limits.FiveHour) : Dim), + (Limits.Week, Limits.Week >= 0 ? UsageColor(Limits.Week) : Dim), + ((float)ctxFrac, ctxCol), + }; + + int hotRing = -1; + if (WidgetInput.Over) + { + float dx = WidgetInput.Mouse.X - RingCx, dy = WidgetInput.Mouse.Y - RingCy; + float dist = MathF.Sqrt(dx * dx + dy * dy); + for (int i = 0; i < rings.Length; i++) + if (MathF.Abs(dist - (RingOuter - i * RingStep)) <= RingBand / 2f + 3f) { hotRing = i; break; } + } + + long ringNow = Environment.TickCount64; + float rdt = _ringTick == 0 ? 1f / 60f : Math.Clamp((ringNow - _ringTick) / 1000f, 0.001f, 0.1f); + _ringTick = ringNow; + for (int i = 0; i < _ringLift.Length; i++) + _ringLift[i] += ((hotRing == i ? 1f : 0f) - _ringLift[i]) * (1f - MathF.Exp(-rdt / 0.09f)); + + for (int i = 0; i < rings.Length; i++) + { + float lift = _ringLift[i]; + float r = RingOuter - i * RingStep; + float band = RingBand + 3.2f * lift; + using (var track = new Pen(Mul(Track, a * (1f + 0.5f * lift)), band)) + g.DrawArc(track, RingCx - r, RingCy - r, r * 2, r * 2, 0, 360); + + if (rings[i].frac < 0) continue; + float sweep = Math.Clamp(rings[i].frac, 0f, 1f) * 360f; + if (sweep <= 0.5f) continue; + + float other = hotRing >= 0 ? 1f - 0.35f * (1f - lift) : 1f; + using var arc = new Pen(Mul(rings[i].col, a * other), band) { StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawArc(arc, RingCx - r, RingCy - r, r * 2, r * 2, -90f, sweep); + } + + float show = 0f; + int shown = -1; + for (int i = 0; i < _ringLift.Length; i++) + if (_ringLift[i] > show) { show = _ringLift[i]; shown = i; } + if (shown >= 0 && show > 0.01f) + { + var (rf, rc) = rings[shown]; + string big = rf < 0 ? "\u2014" : $"{Math.Clamp(rf, 0f, 1f) * 100:0}%"; + string cap2 = shown switch + { + 0 => Limits.FiveHour < 0 ? "5-hour \u00b7 not fetched" + : Limits.CreditsUsed > 0 ? $"5-hour \u00b7 {ResetIn(Limits.FiveHourReset)} left \u00b7 ${Limits.CreditsUsed:0.00}" + : $"5-hour \u00b7 {ResetIn(Limits.FiveHourReset)} left", + 1 => Limits.Week >= 0 ? $"weekly \u00b7 {ResetIn(Limits.WeekReset)} left" : "weekly \u00b7 not fetched", + _ => st?.Session is { ContextMax: > 0 } ses + ? $"context \u00b7 {ses.ContextUsed / 1000}K of {ses.ContextMax / 1000}K" : "context \u00b7 no session", + }; + + float hole = RingOuter - 2 * RingStep - RingBand / 2f - 2f; + Font centreF = new("Segoe UI Semibold", 15f, GraphicsUnit.Pixel); + foreach (float px in new[] { 15f, 14f, 13f, 12f, 11f, 10f, 9f }) + { + var probe = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel); + float half = probe.Height / 2f; + float chord = 2f * MathF.Sqrt(MathF.Max(1f, hole * hole - half * half)); + if (Advance(g, big, probe) <= chord || px <= 9f) { centreF.Dispose(); centreF = probe; break; } + probe.Dispose(); + } + using var _centreF = centreF; + using var underF = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + using var mid = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }; + using (var cb = new SolidBrush(Mul(rf < 0 ? Dim : rc, a * show))) + g.DrawString(big, centreF, cb, new RectangleF(RingCx - 30, RingCy - 11, 60, 22), mid); + using (var ub = new SolidBrush(Mul(Dim, a * show * 0.95f))) + + g.DrawString(cap2, underF, ub, + new RectangleF(RingCx - 74, RingCy + RingOuter + RingBand / 2f + 7f, 148, 16), mid); + } + + bool KeyHover(int i) => WidgetInput.Over + && WidgetInput.Mouse.X >= KeyX - 20 && WidgetInput.Mouse.X < ColR - 8 + && WidgetInput.Mouse.Y >= Row0 + i * RowPitch - 16 && WidgetInput.Mouse.Y < Row0 + i * RowPitch + 20; + + void Key(int i, Color swatch, string cap, string value, string sub, Color? figure = null, + string? hot = null) + { + float b1 = Row0 + i * RowPitch, b2 = b1 + 17; + using (var sb = new SolidBrush(Mul(swatch, a))) + g.FillEllipse(sb, KeyX - 20, b1 - 9, 9, 9); + using (var cb = new SolidBrush(Mul(Dim, a * 0.85f))) + Text(g, cap, keyCap, cb, KeyX, b1); + using (var vb = new SolidBrush(Mul(figure ?? White, a))) + Text(g, value, keyVal, vb, KeyValX, b1); + if (sub.Length == 0) return; + int cut = hot is { Length: > 0 } ? sub.IndexOf(hot, StringComparison.Ordinal) : -1; + if (cut < 0) + { + using var ub = new SolidBrush(Mul(Dim, a * 0.8f)); + TextClipped(g, sub, keySub, ub, KeyX, b2, ColR - KeyX - 12); + return; + } + using (var ub = new SolidBrush(Mul(Dim, a * 0.8f))) + using (var hb = new SolidBrush(Mul(figure ?? White, a * 0.95f))) + { + string pre = sub.Substring(0, cut), post = sub.Substring(cut + hot!.Length); + float x = KeyX; + Text(g, pre, keySub, ub, x, b2); + x += Advance(g, pre, keySub); + Text(g, hot!, keySub, hb, x, b2); + x += Advance(g, hot!, keySub); + Text(g, post, keySub, ub, x, b2); + } + } + + int slot = 0; + + if (Limits.FiveHour >= 0) + { + int s = slot++; + string sub = KeyHover(s) ? $"resets {Limits.FiveHourReset.ToLocalTime():ddd HH:mm}" + : $"{ResetIn(Limits.FiveHourReset)} left"; + + if (Limits.CreditsUsed > 0 && KeyHover(s)) + sub += Limits.CreditsBalance >= 0 ? $" · ${Limits.CreditsBalance:0.00} left" + : Limits.CreditsLimit > 0 ? $" · ${Math.Max(0, Limits.CreditsLimit - Limits.CreditsUsed):0.00} of ${Limits.CreditsLimit:0}" + : $" · ${Limits.CreditsUsed:0.00} used"; + Key(s, UsageColor(Limits.FiveHour), "5-hour", + KeyHover(s) ? $"{Limits.FiveHour * 100:0.#}%" : Pct(Limits.FiveHour), sub, + UsageColor(Limits.FiveHour)); + } + + else Key(slot++, Dim, "5-hour", "\u2014", ""); + + if (Limits.Week >= 0) + { + int s = slot++; + Key(s, UsageColor(Limits.Week), "weekly", + KeyHover(s) ? $"{Limits.Week * 100:0.#}%" : Pct(Limits.Week), + KeyHover(s) ? $"resets {Limits.WeekReset.ToLocalTime():ddd HH:mm}" + : $"{ResetIn(Limits.WeekReset)} left", + UsageColor(Limits.Week)); + } + + if (st?.Session is { } sess) + { + long maxK = sess.ContextMax / 1000, usedK = Math.Min(sess.ContextUsed / 1000, maxK); + string maxLabel = maxK >= 1000 ? $"{maxK / 1000f:0.#}M" : $"{maxK}K"; + Key(slot, ctxCol, "context", $"{usedK}K", $"of {maxLabel} · {ctxFrac * 100:0}% used", ctxCol, + $"{ctxFrac * 100:0}%"); + } + else Key(slot, Dim, "context", "\u2014", "no active session"); + + DrawNet(g, ColR, 74, RightEdge - ColR, 38, a); + ExitBlock.Draw(g, a, keySub, keyCap, ColR, RightEdge, + NetMon.Snapshot().api, NetMon.Empty, NetMon.Lost); + + var rr = RefreshRect(w, h); + bool rHover = WidgetInput.Over && rr.Contains(WidgetInput.Mouse); + using (var rb = new SolidBrush(Mul(rHover ? White : Dim, a * (rHover ? 1f : 0.65f)))) + using (var rsf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, FormatFlags = StringFormatFlags.NoWrap }) + { + string label = rHover + ? (Limits.LastSuccess == DateTime.MinValue ? "never fetched · \u27f3 refresh" + : $"updated {AgeText(DateTime.UtcNow - Limits.LastSuccess)} · \u27f3 refresh") + : "\u27f3 refresh"; + g.DrawString(label, keySub, rb, rr, rsf); + } + + DrawNetHover(g, a); + } + + internal static RectangleF ExitRect() => ExitBlock.Rect(ColR, RightEdge); + + private void DrawCancel(Graphics g, int w, int h, float a, Color state) + { + var r = CancelRect(w, h); + g.SmoothingMode = SmoothingMode.AntiAlias; + if (!CanCancel) + { + const float d = 15f; + using var glow = new SolidBrush(Mul(Color.FromArgb(38, state), a)); + g.FillEllipse(glow, r.X + (r.Width - d * 1.9f) / 2, r.Y + (r.Height - d * 1.9f) / 2, d * 1.9f, d * 1.9f); + using var lamp = new SolidBrush(Mul(state, a)); + g.FillEllipse(lamp, r.X + (r.Width - d) / 2, r.Y + (r.Height - d) / 2, d, d); + return; + } + using (var b = new SolidBrush(Mul(Color.FromArgb(46, Red), a))) + g.FillEllipse(b, r.X, r.Y, r.Width, r.Height); + using (var pen = new Pen(Mul(Red, a), 1.4f)) + g.DrawEllipse(pen, r.X, r.Y, r.Width, r.Height); + float sq = r.Width * 0.34f; + using (var sb = new SolidBrush(Mul(Red, a))) + using (var sp = Rounded(new RectangleF(r.X + (r.Width - sq) / 2, r.Y + (r.Height - sq) / 2, sq, sq), 2f)) + g.FillPath(sb, sp); + } + + private (int[] net, int[] api, float x0, float step, int first, int count, + float top, float bottom, float right)? _hover; + + private void DrawNet(Graphics g, float colX, float topY, float colW, float colH, float a) + { + var (net, api) = NetMon.Snapshot(); + int n = net.Length; + + bool hasData = false; + foreach (var v in net) if (v != NetMon.Empty) { hasData = true; break; } + if (!hasData) foreach (var v in api) if (v != NetMon.Empty) { hasData = true; break; } + + float mid = topY + colH / 2f, half = colH / 2f - 1f; + float span = colW - 4f; + + g.SmoothingMode = SmoothingMode.AntiAlias; + + _hover = null; + if (!hasData) + { + using var wf = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var wb = new SolidBrush(Mul(Dim, a * 0.7f)); + using var wsf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString("sampling…", wf, wb, new RectangleF(colX, topY, colW, colH), wsf); + return; + } + + var seen = new List(); + foreach (var v in net) if (v >= 0) seen.Add(v); + foreach (var v in api) if (v >= 0) seen.Add(v); + int cap = 150; + if (seen.Count > 0) + { + seen.Sort(); + cap = Math.Max(cap, seen[seen.Count / 2] * 3); + } + cap = (cap + 49) / 50 * 50; + + int first = n; + for (int i = 0; i < n; i++) + if (net[i] != NetMon.Empty || api[i] != NetMon.Empty) { first = i; break; } + int count = n - first; + + float slot = count > 0 ? span / count : span; + float X(int i) => colX + 2f + i * slot + slot / 2f; + + float Mag(int v) => v == NetMon.Lost ? half + : v == NetMon.Empty ? 1.2f + : Math.Max(1.6f, half * 0.94f * Math.Clamp(v / (float)cap, 0.02f, 1f)); + + float Age(int i) => count < 2 ? 1f : 0.45f + 0.55f * (i / (float)(count - 1)); + + void Rule(float alpha) + { + using var rule = new Pen(Mul(Dim, a * alpha), 1f); + g.DrawLine(rule, colX, mid, colX + colW, mid); + } + + void Waveform() + { + Rule(0.22f); + + float barW = Math.Clamp(slot - 2.2f, 2f, 5.5f); + for (int i = 0; i < count; i++) + { + void Cap(int v, Color col, bool up) + { + if (v == NetMon.Empty) return; + bool lost = v == NetMon.Lost; + float m = Mag(v); + var r = up ? new RectangleF(X(i) - barW / 2f, mid - 1.5f - m, barW, m) + : new RectangleF(X(i) - barW / 2f, mid + 1.5f, barW, m); + using var b = new SolidBrush(Mul(lost ? Red : col, a * Age(i) * (lost ? 1f : 0.92f))); + using var p = Rounded(r, barW / 2f); + g.FillPath(b, p); + } + Cap(net[first + i], Green, true); + Cap(api[first + i], Blue, false); + } + } + + Waveform(); + + int lastN = LastSample(net), lastA = LastSample(api); + string tn = Fx.NetLabel + " " + (lastN == NetMon.Empty ? "…" : lastN == NetMon.Lost ? ":(" : lastN.ToString()); + string ta = Fx.ApiLabel + " " + (lastA == NetMon.Empty ? "…" : lastA == NetMon.Lost ? ":(" : lastA + " ms"); + using (var f = new Font("Segoe UI", 13f, GraphicsUnit.Pixel)) + { + float bl = topY - 8; + using (var b = new SolidBrush(Mul(lastN == NetMon.Lost ? Red : Green, a))) + Text(g, tn, f, b, colX, bl); + float wN = g.MeasureString(tn, f, PointF.Empty, StringFormat.GenericTypographic).Width; + using (var b = new SolidBrush(Mul(Dim, a * 0.7f))) + Text(g, "·", f, b, colX + wN + 6, bl); + using (var b = new SolidBrush(Mul(lastA == NetMon.Lost ? Red : Blue, a))) + Text(g, ta, f, b, colX + wN + 18, bl); + } + + _hover = (net, api, colX + 2f, slot, first, count, topY, topY + colH, colX + colW); + } + + private void DrawNetHover(Graphics g, float a) + { + if (_hover is not { } hv) return; + var (net, api, x0, step, first, count, top, bottom, right) = hv; + var m = WidgetInput.Mouse; + if (!WidgetInput.Over || m.X < x0 || m.X > right || m.Y < top - 10 || m.Y > bottom + 10) return; + if (count <= 0) return; + + int rel = step > 0 ? (int)((m.X - x0) / step) : 0; + int idx = first + Math.Clamp(rel, 0, count - 1); + int vN = net[idx], vA = api[idx]; + if (vN == NetMon.Empty && vA == NetMon.Empty) return; + + float gx = x0 + (idx - first) * step; + using (var guide = new Pen(Mul(White, a * 0.30f), 1f) { DashStyle = DashStyle.Dot }) + g.DrawLine(guide, gx, top, gx, bottom); + + int lostN = 0, cntN = 0, lostA = 0, cntA = 0; + for (int i = 0; i < net.Length; i++) + { + if (net[i] != NetMon.Empty) { cntN++; if (net[i] == NetMon.Lost) lostN++; } + if (api[i] != NetMon.Empty) { cntA++; if (api[i] == NetMon.Lost) lostA++; } + } + string F(int v) => v == NetMon.Lost ? ":(" : v == NetMon.Empty ? "–" : $"{v} ms"; + var lines = new List<(string t, Color c)> + { + ($"{Fx.NetLabel} {F(vN)} {Fx.ApiLabel} {F(vA)}", White), + ($"{Fx.LossLabel} {Fx.NetLabel} {lostN}/{cntN} · {Fx.ApiLabel} {lostA}/{cntA}", Dim), + ("google.com · api.anthropic.com", Dim), + }; + if (vA == NetMon.Lost && vN >= 0) lines.Add(("Anthropic's side :(", Amber)); + else if (vN == NetMon.Lost) lines.Add(("your internet :(", Red)); + + using var f2 = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + float bw2 = 0; + foreach (var l in lines) bw2 = Math.Max(bw2, g.MeasureString(l.t, f2).Width); + bw2 += 16; + float bh2 = lines.Count * 15 + 10; + float bx = Math.Clamp(gx - bw2 / 2f, Pad, right - bw2); + float by = bottom + 8; + if (by + bh2 > 214) by = top - bh2 - 8; + using (var path = Rounded(new RectangleF(bx, by, bw2, bh2), 7)) + { + using (var bg = new SolidBrush(Mul(Color.FromArgb(255, 16, 16, 18), a))) g.FillPath(bg, path); + using (var pen = new Pen(Mul(Track, a), 1f)) g.DrawPath(pen, path); + } + for (int i = 0; i < lines.Count; i++) + using (var b = new SolidBrush(Mul(lines[i].c, a))) + g.DrawString(lines[i].t, f2, b, bx + 8, by + 5 + i * 15); + } + + private static int LastSample(int[] s) + { + for (int i = s.Length - 1; i >= 0; i--) if (s[i] != NetMon.Empty) return s[i]; + return NetMon.Empty; + } + + private static RectangleF CancelRect(int w, int h) => new(42, 16, 34, 34); + + private static RectangleF RefreshRect(int w, int h) => new(RightEdge - 210, 22, 210, 20); + + private static string AgeText(TimeSpan d) => + d.TotalMinutes < 1 ? "just now" + : d.TotalHours < 1 ? $"{(int)d.TotalMinutes}m ago" + : d.TotalDays < 1 ? $"{(int)d.TotalHours}h ago" + : $"{(int)d.TotalDays}d ago"; + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + { + var list = new List<(RectangleF, Action)> + { + (CancelRect(w, h), _ => { if (CanCancel) _cancel(); }), + (RefreshRect(w, h), _ => Limits.ForceRefresh()), + }; + + if (ExitBlock.DnsRowRect != RectangleF.Empty) + list.Add((ExitBlock.DnsRowRect, _ => DnsLeak.Retest())); + return list; + } + + private static void DrawBar(Graphics g, float x, float y, float w, string label, string value, + double frac, Color fill, float a, Font labelFont, Font valueFont) + { + using (var lb = new SolidBrush(Mul(White, a))) + g.DrawString(label, labelFont, lb, x, y); + var sz = g.MeasureString(value, valueFont); + using (var vb = new SolidBrush(Mul(Dim, a))) + g.DrawString(value, valueFont, vb, x + w - sz.Width, y + 1); + + float by = y + 24, bh = 6; + Fill(g, x, by, w, bh, Mul(Track, a)); + double f = Math.Clamp(frac, 0, 1); + if (f > 0) + Fill(g, x, by, (float)(w * f), bh, Mul(fill, a)); + } + + private static void Fill(Graphics g, float x, float y, float w, float h, Color c) + { + if (w <= 0) return; + using var path = Rounded(new RectangleF(x, y, w, h), h / 2f); + using var b = new SolidBrush(c); + g.FillPath(b, path); + } + + private static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = Math.Min(radius * 2, Math.Min(r.Width, r.Height)); + var p = new GraphicsPath(); + if (d <= 0) { p.AddRectangle(r); return p; } + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); + + private static double ContextFrac(CcStatus? st) + { + var s = st?.Session; + if (s == null || s.ContextMax <= 0) return 0; + return Math.Clamp((double)s.ContextUsed / s.ContextMax, 0, 1); + } + + private MoodContext Mood(CcStatus? st) => new( + Running(st), (float)ContextFrac(st), UsageFrac(), + st?.Session?.PromptTokens ?? 0, ToolRuns(st), DateTime.Now.Hour, Glow(st).Target); + + private const int AfterglowMs = 9_000; + private string? _glowTool, _glowTarget, _glowTurn; + private long _glowAt; + + private (string? Tool, string? Target) Glow(CcStatus? st) + { + var turn = st?.StartedAt; + if (turn != _glowTurn) { _glowTurn = turn; _glowTool = _glowTarget = null; } + if (st?.CurrentTool is { Length: > 0 } cur) + { + _glowTool = cur; + _glowTarget = st.ToolTarget; + _glowAt = Environment.TickCount64; + return (cur, _glowTarget); + } + + if (Shown(st) != "working") { _glowTool = _glowTarget = null; return (null, null); } + return Environment.TickCount64 - _glowAt <= AfterglowMs ? (_glowTool, _glowTarget) : (null, null); + } + + private string? _runsTurn; + private string? _runsTool; + private int _runs; + + private int ToolRuns(CcStatus? st) + { + var stamp = st?.StartedAt; + if (stamp != _runsTurn) { _runsTurn = stamp; _runsTool = null; _runs = 0; } + var tool = st?.CurrentTool; + if (!string.IsNullOrEmpty(tool) && tool != _runsTool) { _runsTool = tool; _runs++; } + return _runs; + } + + private static float UsageFrac() + => Limits.FiveHour >= 0 ? Limits.FiveHour : Limits.Week >= 0 ? Limits.Week : 0f; + + private static bool RingIsTheMessage(CcStatus? st) + => NetMon.ApiDown || NetMon.NetDown || LimitHit || Compacting(st); + + private static Color RingBase(CcStatus? st, string? tool) + => NetMon.ApiDown || NetMon.NetDown ? Red + : LimitHit ? White + + : st?.State == "waiting_input" ? Fx.SlotColor("asking") + : Compacting(st) ? Blue + : JustCompacted(st) ? Mint + : Shown(st) == "working" ? Fx.SlotColor(ToolSlot(tool)) + : White; + + private Color RingColor(CcStatus? st) + { + var tool = Glow(st).Tool; + var b = RingBase(st, tool); + if (RingIsTheMessage(st)) return b; + + bool hueIsFree = st?.State != "waiting_input" + && (Shown(st) != "working" || string.IsNullOrEmpty(tool)); + return Fx.MoodRing(b, Mood(st), hueIsFree); + } + + private static string Pct(float f) => $"{(int)Math.Round(f * 100)}%"; + + private static Color LerpC(Color a, Color b, float t) => Color.FromArgb( + (int)(a.A + (b.A - a.A) * t), (int)(a.R + (b.R - a.R) * t), + (int)(a.G + (b.G - a.G) * t), (int)(a.B + (b.B - a.B) * t)); + + internal static Color UsageColorForTest(float f) => UsageColor(f); + + private static Color UsageColor(float f) => Fx.UsageColor(f); + + private static string ResetIn(DateTimeOffset r) + { + if (r == default) return ""; + var d = r - DateTimeOffset.UtcNow; + if (d.TotalSeconds <= 0) return "now"; + if (d.TotalDays >= 1) return $"{(int)d.TotalDays}d {d.Hours}h"; + if (d.TotalHours >= 1) return $"{(int)d.TotalHours}h {d.Minutes}m"; + return $"{d.Minutes}m"; + } + + private static bool LimitHit => + (Limits.FiveHour >= 0.99f || Limits.Week >= 0.99f) && !Limits.ExtraUsageOn && Limits.CreditsUsed >= 0; + + private static string LimitReset() + { + var r = ResetIn(Limits.FiveHour >= 0.99f ? Limits.FiveHourReset : Limits.WeekReset); + return r.Length > 0 ? "back in " + r : ""; + } + + private static string? Trouble(CcStatus? st) => + NetMon.NetDown ? Moods.Line("offline") + : NetMon.ApiDown ? Moods.Line("apiDown") + : JustCompacted(st) ? Moods.Line("compacted") + : Limits.FiveHour >= 0.95f && !Limits.ExtraUsageOn && Limits.CreditsUsed >= 0 ? Moods.Line("outOfCredit") + : null; + + private static string IdleMood(CcStatus? st, in MoodContext ctx) => Trouble(st) ?? Moods.Line("idle", ctx); + + private static bool JustCompacted(CcStatus? st) => + DateTimeOffset.TryParse(st?.CompactedAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t) + && DateTimeOffset.UtcNow - t < TimeSpan.FromSeconds(20); + + private static string? OutageText() => + NetMon.NetDown ? Moods.Line("netError") : NetMon.ApiDown ? Moods.Line("apiError") : null; + + internal static string? ToolSlot(string? tool) => tool switch + { + "Edit" or "Write" or "MultiEdit" or "NotebookEdit" => "writing", + "Read" => "reading", + "Bash" or "PowerShell" or "KillShell" => "running", + "BashOutput" or "Monitor" => "watching", + "Grep" or "Glob" or "ToolSearch" => "digging", + "WebFetch" => "fetching", + "WebSearch" => "searching", + "Task" or "Agent" or "SendMessage" => "delegating", + "TodoWrite" or "TaskCreate" or "TaskUpdate" or "ExitPlanMode" or "EnterPlanMode" + or "ScheduleWakeup" or "CronCreate" => "planning", + "SlashCommand" or "Skill" => "skill", + "AskUserQuestion" => "asking", + "ReportFindings" => "reviewing", + "Artifact" or "SendUserFile" => "publishing", + null or "" => "unknown", + + _ when tool.StartsWith("mcp__", StringComparison.Ordinal) => "consulting", + _ => null, + }; + + private static string ToolVerb(string? tool, in MoodContext ctx) + => ToolSlot(tool) is { } slot ? Moods.Line(slot, ctx) : Moods.PrettyTool(tool); + + private static TimeSpan? Running(CcStatus? st) => + ParseTime(st?.StartedAt) is { } t ? DateTimeOffset.UtcNow - t : null; + + private static string Elapsed(CcStatus? st) + { + if ((Shown(st) != "working" && !Compacting(st)) || string.IsNullOrEmpty(st?.StartedAt)) return ""; + if (!DateTimeOffset.TryParse(st.StartedAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t)) return ""; + var d = DateTimeOffset.UtcNow - t; + if (d.TotalSeconds < 1) return ""; + return d.TotalMinutes >= 1 ? $"{(int)d.TotalMinutes}m {d.Seconds}s" : $"{d.Seconds}s"; + } +} diff --git a/src/Halo.App/Widgets/CodexWidget.cs b/src/Halo.App/Widgets/CodexWidget.cs new file mode 100644 index 0000000..8e97513 --- /dev/null +++ b/src/Halo.App/Widgets/CodexWidget.cs @@ -0,0 +1,871 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using Halo.Codex; + +using Halo.Agents; + +namespace Halo.Widgets; + +internal enum CodexCancelRoute { None, Cli, Desktop } + +internal sealed class CodexWidget : IWidget +{ + private static readonly Color Blue = Color.FromArgb(91, 157, 255); + private static readonly Color Green = Color.FromArgb(62, 207, 92); + private static readonly Color Amber = Color.FromArgb(255, 176, 32); + private static readonly Color Red = Color.FromArgb(229, 72, 77); + private static readonly Color Mint = Color.FromArgb(82, 224, 163); + private const float MinVerbPx = 12.5f; + private static readonly Color Track = Color.FromArgb(38, 255, 255, 255); + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + + private readonly CodexStatusStore _store; + private readonly CodexSurface _surface; + private readonly Action _cancel; + private readonly Func _canCancelDesktop; + private readonly Action _observeLimits; + + public CodexWidget(CodexStatusStore store, CodexSurface surface, Action cancel, + Func? canCancelDesktop = null, Action? observeLimits = null) + { + _store = store; + _surface = surface; + _cancel = cancel; + _canCancelDesktop = canCancelDesktop ?? (static () => false); + _observeLimits = observeLimits ?? CodexLimits.UpdateFrom; + CodexLimits.Attach(store); + } + + private CodexSnapshot? Current => _store.Candidate(_surface); + + private static readonly Bitmap? OpenAiIcon = LoadIcon(); + internal static Bitmap? PlainIcon => OpenAiIcon; + public float IconOffsetX => -1.25f; + + private static readonly Color Accent = Fx.AccentOf(OpenAiIcon) is var a && a != Fx.White + ? a : Color.FromArgb(16, 163, 127); + + public string Icon => "\uE756"; + + private Bitmap? _badged; + + public Bitmap? IconImage + { + get + { + if (OpenAiIcon is null) return null; + var other = _surface == CodexSurface.Desktop ? CodexSurface.Cli : CodexSurface.Desktop; + if (_store.Candidate(other) is null) return OpenAiIcon; + return _badged ??= Fx.Badge(OpenAiIcon, _surface == CodexSurface.Desktop ? '1' : '2'); + } + } + + public string Id => "codex"; + public string? AgentState => Current?.State; + + public bool IsActive => Current is not null; + public Color? Ring => Current is { } st ? RingColor(st) : null; + + public float RingProgress + => Current is null || (CodexLimits.PrimaryFrac < 0 && CodexLimits.SecondaryFrac < 0) ? -1f : UsageFrac(); + public int Version => _store.Version + CodexNetMon.Version + CodexLimits.Version; + public bool IsDesktop => _surface == CodexSurface.Desktop; + public AgentNotice AgentNotice => Current is { } status + ? new AgentNotice(status.State, status.CompactedAt, status.Message) + : AgentNotice.None; + public IEnumerable OwnerPids => Current is { } st ? new[] { st.Pid, st.ConsolePid } : Array.Empty(); + + public bool Animating => _appear < 1f || Compacting(Current) + || (_wasOpen && (WidgetInput.Over || RingsSettling)); + + private string _shownKey = ""; + private float _appear = 1f; + + private readonly float[] _ringLift = new float[3]; + private long _ringTick; + private bool RingsSettling + { + get { foreach (var v in _ringLift) if (v > 0.01f) return true; return false; } + } + + private static Bitmap? LoadIcon() + { + try + { + using var s = typeof(CodexWidget).Assembly.GetManifestResourceStream("Halo.Assets.openai.png"); + return s != null ? new Bitmap(s) : null; + } + catch { return null; } + } + + private bool CanCancel + { + get + { + var snapshot = Current; + var canCancelDesktop = snapshot is { Source: CodexSurface.Desktop, State: "working" } && + _canCancelDesktop(); + return GetCancelRoute(snapshot, canCancelDesktop) != CodexCancelRoute.None; + } + } + + internal static CodexCancelRoute GetCancelRoute(CodexSnapshot? snapshot, bool canCancelDesktop) => + TurnOver(snapshot, DateTimeOffset.UtcNow) ? CodexCancelRoute.None : snapshot switch + { + { Source: CodexSurface.Cli, State: "working", ConsolePid: > 0 } => CodexCancelRoute.Cli, + { Source: CodexSurface.Desktop, State: "working" } when canCancelDesktop => CodexCancelRoute.Desktop, + _ => CodexCancelRoute.None, + }; + + private bool _wasOpen; + + public void DrawContent(Graphics g, int w, int h, float fade) + { + bool open = fade > 0.01f; + if (open && !_wasOpen) CodexLimits.ForceRefresh(); + _wasOpen = open; + if (open) + { + var snapshot = Current; + if (snapshot is not null) CodexLimits.UpdateFrom(snapshot); + CodexNetMon.Poke(); + Halo.ClaudeCode.IpCountry.Poke(); + Fx.Glow(g, w, h, fade, w * 0.16f, h * 0.35f, w * 0.85f, h * 1.2f, 30, Accent); + DrawExpanded(g, w, h, fade, snapshot); + } + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + var st = Current; + _observeLimits(st); + float sz = (h - 16f) * 0.82f, x = 13, y = (h - sz) / 2f; + g.SmoothingMode = SmoothingMode.AntiAlias; + + if (!Compacting(st)) Fx.PillBar(g, w, h, fade, UsageFrac(), Accent, 0.3f); + Fx.Glow(g, w, h, fade, x + sz / 2f, h / 2f, w * 0.7f, h * 2.2f, 26, Accent); + if (Compacting(st)) + { + float pulse = 0.5f - 0.5f * MathF.Cos(Environment.TickCount % 2400 / 2400f * MathF.Tau); + using var pb = new SolidBrush(Mul(Blue, fade * (0.05f + 0.11f * pulse))); + using var pp = Fx.PillPath(w, h, h / 2f); + g.FillPath(pb, pp); + } + + using (var pen = new Pen(Mul(RingColor(st), fade * 0.9f), 1.9f)) + g.DrawEllipse(pen, x - 2.5f, y - 2.5f, sz + 5f, sz + 5f); + if (OpenAiIcon != null) DrawIcon(g, OpenAiIcon, x, y, sz, fade, sz / 2f); + else + using (var db = new SolidBrush(Mul(RingColor(st), fade))) + g.FillEllipse(db, x, y, sz, sz); + + string el0 = LimitHit ? LimitReset() : Elapsed(st); + + float textX0 = x + sz + 11; + if (st?.State == "waiting_input") textX0 += 16; + using var elFont = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + float elW0 = el0.Length > 0 + ? g.MeasureString(el0, elFont, int.MaxValue, StringFormat.GenericTypographic).Width : 0; + float avail0 = (w - 14) - textX0 - (elW0 > 0 ? elW0 + 10 : 0); + + int fit = fade > 0.99f ? Fx.FitChars(g, avail0, MinVerbPx) : 0; + var mood = Mood(st) with { MaxChars = fit >= 8 ? fit : 0 }; + string verb = OutageText() ?? (LimitHit ? "outta juice :(" : Shown(st) switch + { + "working" => ToolVerb(Glow(st), mood), + "compacting" when Compacting(st) => Moods.Line("compacting", mood), + "waiting_input" => "your move ;)", + _ => IdleMood(st, mood), + }); + string el = el0; + if (verb != _shownKey) { _shownKey = verb; _appear = 0f; } + else if (_appear < 1f) _appear = Math.Min(1f, _appear + 0.1f); + float e = 1f - MathF.Pow(1f - _appear, 3); + bool busy = Shown(st) == "working" || Compacting(st) || LimitHit; + bool centred = !busy && st?.State != "waiting_input"; + + float textX = x + sz + 11; + if (st?.State == "waiting_input") textX += 16; + using var tf2 = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + float elW = el.Length > 0 ? g.MeasureString(el, tf2, int.MaxValue, StringFormat.GenericTypographic).Width : 0; + float avail = (w - 14) - textX - (elW > 0 ? elW + 10 : 0); + + float px = 15f; + using (var fm = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel)) + { + var m0 = g.MeasureString(verb, fm, int.MaxValue, StringFormat.GenericTypographic); + if (m0.Width > avail && m0.Width > 0) px = Math.Max(MinVerbPx, px * avail / m0.Width); + } + using var f = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade * e)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { + Alignment = centred ? StringAlignment.Center : StringAlignment.Near, + LineAlignment = StringAlignment.Center, + FormatFlags = StringFormatFlags.NoWrap, + + Trimming = StringTrimming.EllipsisCharacter, + }; + + float originX = textX - 16f * (1f - e); + float rightEdge = textX + avail; + var clip = g.Clip; + g.SetClip(new RectangleF(x + sz + 2, 0, rightEdge - (x + sz + 2), h)); + + float zoneW = (centred ? rightEdge - 34f : rightEdge) - originX; + + g.DrawString(verb, f, b, new RectangleF(originX, -Fx.CenterLift(f), zoneW, h), sf); + g.Clip = clip; + + if (elW > 0) + using (var eb = new SolidBrush(Mul(Dim, fade * e))) + using (var esf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }) + g.DrawString(el, tf2, eb, new RectangleF(w - 14 - elW - 4, -Fx.CenterLift(tf2), elW + 4, h), esf); + + } + + private static DateTimeOffset? _cancelledCompactKey; + + public static void MarkCompactCancelled(DateTimeOffset? startedAt) => _cancelledCompactKey = startedAt; + + private static DateTimeOffset? _cancelledTurnKey; + + public static void MarkTurnCancelled(DateTimeOffset? startedAt) => _cancelledTurnKey = startedAt; + + internal const int SettleAfterSeconds = 180; + + internal static bool TurnOver(CodexSnapshot? st, DateTimeOffset now) + { + if (st is not { State: "working" }) return false; + if (st.StartedAt is { } started && started == _cancelledTurnKey) return true; + if (!string.IsNullOrEmpty(st.CurrentTool)) return false; + + return st.UpdatedAt != default && now - st.UpdatedAt > TimeSpan.FromSeconds(SettleAfterSeconds); + } + + private static string? Shown(CodexSnapshot? st) => + TurnOver(st, DateTimeOffset.UtcNow) ? "idle" : st?.State; + + private static bool Compacting(CodexSnapshot? st) => + st?.State == "compacting" && st.StartedAt is { } t && t != _cancelledCompactKey + && DateTimeOffset.UtcNow - t < TimeSpan.FromMinutes(3); + + private static void DrawIcon(Graphics g, Bitmap img, float x, float y, float size, float fade, float radius) + { + using var path = Rounded(new RectangleF(x, y, size, size), radius); + int s = Math.Max(1, (int)Math.Ceiling(size)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = fade }); + int side = Math.Min(img.Width, img.Height); + sg.DrawImage(img, new Rectangle(0, 0, s, s), (img.Width - side) / 2, (img.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(x, y); + g.FillPath(tb, path); + } + + private const int Pad = 22; + private const float ColR = 356f, RightEdge = 538f; + private const float RingCx = 84f, RingCy = 132f, RingOuter = 52f, RingBand = 8f, RingStep = 16f; + private const float KeyX = 178f, KeyValX = 268f; + private const float Row0 = 96f, RowPitch = 42f; + + private static float TextTop(Font f, float baseline) + => MathF.Round(baseline - f.FontFamily.GetCellAscent(f.Style) / (float)f.FontFamily.GetEmHeight(f.Style) * f.Size); + + private static void Text(Graphics g, string t, Font f, Brush b, float x, float baseline) + => g.DrawString(t, f, b, MathF.Round(x), TextTop(f, baseline), StringFormat.GenericTypographic); + + private static readonly StringFormat AdvanceFmt = + new(StringFormat.GenericTypographic) { FormatFlags = StringFormatFlags.MeasureTrailingSpaces }; + + private static float Advance(Graphics g, string t, Font f) + => t.Length == 0 ? 0f : g.MeasureString(t, f, System.Drawing.Point.Empty, AdvanceFmt).Width; + + private static void TextClipped(Graphics g, string t, Font f, Brush b, float x, float baseline, float w) + { + using var sf = new StringFormat(StringFormat.GenericTypographic) + { FormatFlags = StringFormatFlags.NoWrap, Trimming = StringTrimming.EllipsisCharacter }; + g.DrawString(t, f, b, new RectangleF(MathF.Round(x), TextTop(f, baseline), w, f.Size * 1.6f), sf); + } + + internal const float ContextWarnAt = 0.80f; + + internal static Color ContextColour(double frac) + => frac < 0 ? Blue + : frac >= ContextWarnAt ? Red + : frac >= ContextWarnAt - 0.15f ? Amber : Blue; + + private void DrawExpanded(Graphics g, int w, int h, float a, CodexSnapshot? st) + { + using var title = new Font("Segoe UI Semibold", 22f, GraphicsUnit.Pixel); + using var line = new Font("Segoe UI", 14f, GraphicsUnit.Pixel); + using var keyCap = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var keyVal = new Font("Segoe UI Semibold", 16f, GraphicsUnit.Pixel); + using var keySub = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + + g.SmoothingMode = SmoothingMode.AntiAlias; + var state = RingColor(st); + + DrawCancel(g, w, h, a, state); + using (var tb = new SolidBrush(Mul(White, a))) + Text(g, "Codex", title, tb, 84, 40); + + if (st?.State == "waiting_input" && !string.IsNullOrEmpty(st.Message)) + using (var ab = new SolidBrush(Mul(Amber, a))) + TextClipped(g, st.Message!, line, ab, 84, 62, ColR - 92); + + double ctxFrac = st is not null && st.ContextMax > 0 ? ContextFrac(st) : -1; + var ctxCol = ContextColour(ctxFrac); + var primary = CodexLimits.Current?.Primary; + var secondary = CodexLimits.Current?.Secondary; + float pFrac = primary is null ? -1f : (float)(primary.UsedPercent / 100d); + float sFrac = secondary is null ? -1f : (float)(secondary.UsedPercent / 100d); + var rings = new (float frac, Color col)[] + { + (pFrac, pFrac >= 0 ? UsageColor(pFrac) : Dim), + (sFrac, sFrac >= 0 ? UsageColor(sFrac) : Dim), + ((float)ctxFrac, ctxCol), + }; + + int hotRing = -1; + if (WidgetInput.Over) + { + float dx = WidgetInput.Mouse.X - RingCx, dy = WidgetInput.Mouse.Y - RingCy; + float dist = MathF.Sqrt(dx * dx + dy * dy); + for (int i = 0; i < rings.Length; i++) + if (MathF.Abs(dist - (RingOuter - i * RingStep)) <= RingBand / 2f + 3f) { hotRing = i; break; } + } + + long ringNow = Environment.TickCount64; + float rdt = _ringTick == 0 ? 1f / 60f : Math.Clamp((ringNow - _ringTick) / 1000f, 0.001f, 0.1f); + _ringTick = ringNow; + for (int i = 0; i < _ringLift.Length; i++) + _ringLift[i] += ((hotRing == i ? 1f : 0f) - _ringLift[i]) * (1f - MathF.Exp(-rdt / 0.09f)); + + for (int i = 0; i < rings.Length; i++) + { + float lift = _ringLift[i]; + float r = RingOuter - i * RingStep; + float band = RingBand + 3.2f * lift; + using (var track = new Pen(Mul(Track, a * (1f + 0.5f * lift)), band)) + g.DrawArc(track, RingCx - r, RingCy - r, r * 2, r * 2, 0, 360); + + if (rings[i].frac < 0) continue; + float sweep = Math.Clamp(rings[i].frac, 0f, 1f) * 360f; + if (sweep <= 0.5f) continue; + + float other = hotRing >= 0 ? 1f - 0.35f * (1f - lift) : 1f; + using var arc = new Pen(Mul(rings[i].col, a * other), band) { StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawArc(arc, RingCx - r, RingCy - r, r * 2, r * 2, -90f, sweep); + } + + float show = 0f; + int shown = -1; + for (int i = 0; i < _ringLift.Length; i++) + if (_ringLift[i] > show) { show = _ringLift[i]; shown = i; } + if (shown >= 0 && show > 0.01f) + { + var (rf, rc) = rings[shown]; + string big = rf < 0 ? "—" : $"{Math.Clamp(rf, 0f, 1f) * 100:0}%"; + string cap2 = shown switch + { + 0 => primary is null ? "no limit reported" + : $"{LimitCaption(primary)} · {ResetIn(primary.ResetsAt ?? default)} left", + 1 => secondary is null ? "no second window" + : $"{LimitCaption(secondary)} · {ResetIn(secondary.ResetsAt ?? default)} left", + _ => ctxFrac >= 0 && st is not null + ? $"context · {st.ContextUsed / 1000}K of {st.ContextMax / 1000}K" : "context · no session", + }; + + float hole = RingOuter - 2 * RingStep - RingBand / 2f - 2f; + Font centreF = new("Segoe UI Semibold", 15f, GraphicsUnit.Pixel); + foreach (float px in new[] { 15f, 14f, 13f, 12f, 11f, 10f, 9f }) + { + var probe = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel); + float half = probe.Height / 2f; + float chord = 2f * MathF.Sqrt(MathF.Max(1f, hole * hole - half * half)); + if (Advance(g, big, probe) <= chord || px <= 9f) { centreF.Dispose(); centreF = probe; break; } + probe.Dispose(); + } + using var _centreF = centreF; + using var underF = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + using var mid = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }; + using (var cb = new SolidBrush(Mul(rf < 0 ? Dim : rc, a * show))) + g.DrawString(big, centreF, cb, new RectangleF(RingCx - 30, RingCy - 11, 60, 22), mid); + using (var ub = new SolidBrush(Mul(Dim, a * show * 0.95f))) + + g.DrawString(cap2, underF, ub, + new RectangleF(RingCx - 74, RingCy + RingOuter + RingBand / 2f + 7f, 148, 16), mid); + } + + bool KeyHover(int i) => WidgetInput.Over + && WidgetInput.Mouse.X >= KeyX - 20 && WidgetInput.Mouse.X < ColR - 8 + && WidgetInput.Mouse.Y >= Row0 + i * RowPitch - 16 && WidgetInput.Mouse.Y < Row0 + i * RowPitch + 20; + + void Key(int i, Color swatch, string cap, string value, string sub, Color? figure = null, + string? hot = null) + { + float b1 = Row0 + i * RowPitch, b2 = b1 + 17; + using (var sb = new SolidBrush(Mul(swatch, a))) + g.FillEllipse(sb, KeyX - 20, b1 - 9, 9, 9); + using (var cb = new SolidBrush(Mul(Dim, a * 0.85f))) + Text(g, cap, keyCap, cb, KeyX, b1); + using (var vb = new SolidBrush(Mul(figure ?? White, a))) + Text(g, value, keyVal, vb, KeyValX, b1); + if (sub.Length == 0) return; + int cut = hot is { Length: > 0 } ? sub.IndexOf(hot, StringComparison.Ordinal) : -1; + if (cut < 0) + { + using var ub = new SolidBrush(Mul(Dim, a * 0.8f)); + TextClipped(g, sub, keySub, ub, KeyX, b2, ColR - KeyX - 12); + return; + } + using (var ub = new SolidBrush(Mul(Dim, a * 0.8f))) + using (var hb = new SolidBrush(Mul(figure ?? White, a * 0.95f))) + { + string pre = sub.Substring(0, cut), post = sub.Substring(cut + hot!.Length); + float x = KeyX; + Text(g, pre, keySub, ub, x, b2); + x += Advance(g, pre, keySub); + Text(g, hot!, keySub, hb, x, b2); + x += Advance(g, hot!, keySub); + Text(g, post, keySub, ub, x, b2); + } + } + + int slot = 0; + + if (primary is { }) + { + int s = slot++; + Key(s, UsageColor(pFrac), LimitCaption(primary), + KeyHover(s) ? $"{pFrac * 100:0.#}%" : Pct(pFrac), + primary.ResetsAt is { } pr + ? (KeyHover(s) ? $"resets {pr.ToLocalTime():ddd HH:mm}" : $"{ResetIn(pr)} left") + : "", + UsageColor(pFrac)); + } + if (secondary is { }) + { + int s = slot++; + Key(s, UsageColor(sFrac), LimitCaption(secondary), + KeyHover(s) ? $"{sFrac * 100:0.#}%" : Pct(sFrac), + secondary.ResetsAt is { } sr + ? (KeyHover(s) ? $"resets {sr.ToLocalTime():ddd HH:mm}" : $"{ResetIn(sr)} left") + : "", + UsageColor(sFrac)); + } + if (primary is null && secondary is null) + Key(slot++, Dim, "usage", "—", "nothing reported yet"); + + if (ctxFrac >= 0 && st is not null) + { + long maxK = st.ContextMax / 1000, usedK = Math.Min(st.ContextUsed / 1000, maxK); + string maxLabel = maxK >= 1000 ? $"{maxK / 1000f:0.#}M" : $"{maxK}K"; + Key(slot, ctxCol, "context", $"{usedK}K", $"of {maxLabel} · {ctxFrac * 100:0}% used", ctxCol, + $"{ctxFrac * 100:0}%"); + } + else Key(slot, Dim, "context", "—", "no active session"); + + DrawNet(g, ColR, 74, RightEdge - ColR, 38, a); + ExitBlock.Draw(g, a, keySub, keyCap, ColR, RightEdge, + CodexNetMon.Snapshot().api, CodexNetMon.Empty, CodexNetMon.Lost); + + var rr = RefreshRect(w, h); + bool rHover = WidgetInput.Over && rr.Contains(WidgetInput.Mouse); + using (var rb = new SolidBrush(Mul(rHover ? White : Dim, a * (rHover ? 1f : 0.65f)))) + using (var rsf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, FormatFlags = StringFormatFlags.NoWrap }) + { + string label = rHover + ? (CodexLimits.LastSuccess == DateTimeOffset.MinValue ? "never read · ⟳ refresh" + : $"read {AgeText(DateTime.UtcNow - CodexLimits.LastSuccess)} · ⟳ refresh") + : "⟳ refresh"; + g.DrawString(label, keySub, rb, rr, rsf); + } + + DrawNetHover(g, a); + } + + internal static string LimitCaption(CodexLimit limit) => LimitCaption(limit.WindowMinutes); + + internal static string LimitCaption(int windowMinutes) => windowMinutes switch + { + <= 0 => "plan", + 10_080 => "weekly", + < 60 => $"{windowMinutes}-min", + < 1440 when windowMinutes % 60 == 0 => $"{windowMinutes / 60}-hour", + < 1440 => $"{windowMinutes / 60}h{windowMinutes % 60}m", + _ when windowMinutes % 1440 == 0 => $"{windowMinutes / 1440}-day", + _ => $"{windowMinutes / 1440}d{windowMinutes % 1440 / 60}h", + }; + + private void DrawCancel(Graphics g, int w, int h, float a, Color state) + { + var r = CancelRect(w, h); + g.SmoothingMode = SmoothingMode.AntiAlias; + if (!CanCancel) + { + const float d = 15f; + using var glow = new SolidBrush(Mul(Color.FromArgb(38, state), a)); + g.FillEllipse(glow, r.X + (r.Width - d * 1.9f) / 2, r.Y + (r.Height - d * 1.9f) / 2, d * 1.9f, d * 1.9f); + using var lamp = new SolidBrush(Mul(state, a)); + g.FillEllipse(lamp, r.X + (r.Width - d) / 2, r.Y + (r.Height - d) / 2, d, d); + return; + } + using (var b = new SolidBrush(Mul(Color.FromArgb(46, Red), a))) + g.FillEllipse(b, r.X, r.Y, r.Width, r.Height); + using (var pen = new Pen(Mul(Red, a), 1.4f)) + g.DrawEllipse(pen, r.X, r.Y, r.Width, r.Height); + float sq = r.Width * 0.34f; + using (var sb = new SolidBrush(Mul(Red, a))) + using (var sp = Rounded(new RectangleF(r.X + (r.Width - sq) / 2, r.Y + (r.Height - sq) / 2, sq, sq), 2f)) + g.FillPath(sb, sp); + } + + private (int[] net, int[] api, float x0, float step, int first, int count, + float top, float bottom, float right)? _hover; + + private void DrawNet(Graphics g, float colX, float topY, float colW, float colH, float a) + { + var (net, api) = CodexNetMon.Snapshot(); + int n = net.Length; + + bool hasData = false; + foreach (var v in net) if (v != CodexNetMon.Empty) { hasData = true; break; } + if (!hasData) foreach (var v in api) if (v != CodexNetMon.Empty) { hasData = true; break; } + + float mid = topY + colH / 2f, half = colH / 2f - 1f; + float span = colW - 4f; + + g.SmoothingMode = SmoothingMode.AntiAlias; + + _hover = null; + if (!hasData) + { + using var wf = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var wb = new SolidBrush(Mul(Dim, a * 0.7f)); + using var wsf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString("sampling…", wf, wb, new RectangleF(colX, topY, colW, colH), wsf); + return; + } + + var seen = new List(); + foreach (var v in net) if (v >= 0) seen.Add(v); + foreach (var v in api) if (v >= 0) seen.Add(v); + int cap = 150; + if (seen.Count > 0) + { + seen.Sort(); + cap = Math.Max(cap, seen[seen.Count / 2] * 3); + } + cap = (cap + 49) / 50 * 50; + + int first = n; + for (int i = 0; i < n; i++) + if (net[i] != CodexNetMon.Empty || api[i] != CodexNetMon.Empty) { first = i; break; } + int count = n - first; + float slot = count > 0 ? span / count : span; + float X(int i) => colX + 2f + i * slot + slot / 2f; + + float Mag(int v) => v == CodexNetMon.Lost ? half + : v == CodexNetMon.Empty ? 1.2f + : Math.Max(1.6f, half * 0.94f * Math.Clamp(v / (float)cap, 0.02f, 1f)); + + float Age(int i) => count < 2 ? 1f : 0.45f + 0.55f * (i / (float)(count - 1)); + + using (var rule = new Pen(Mul(Dim, a * 0.22f), 1f)) + g.DrawLine(rule, colX, mid, colX + colW, mid); + + float barW = Math.Clamp(slot - 2.2f, 2f, 5.5f); + for (int i = 0; i < count; i++) + { + void Cap(int v, Color col, bool up) + { + if (v == CodexNetMon.Empty) return; + bool lost = v == CodexNetMon.Lost; + float m = Mag(v); + var r = up ? new RectangleF(X(i) - barW / 2f, mid - 1.5f - m, barW, m) + : new RectangleF(X(i) - barW / 2f, mid + 1.5f, barW, m); + using var b = new SolidBrush(Mul(lost ? Red : col, a * Age(i) * (lost ? 1f : 0.92f))); + using var p = Rounded(r, barW / 2f); + g.FillPath(b, p); + } + Cap(net[first + i], Green, true); + Cap(api[first + i], Blue, false); + } + + int lastN = LastSample(net), lastA = LastSample(api); + string tn = Fx.NetLabel + " " + (lastN == CodexNetMon.Empty ? "…" : lastN == CodexNetMon.Lost ? ":(" : lastN.ToString()); + string ta = Fx.ApiLabel + " " + (lastA == CodexNetMon.Empty ? "…" : lastA == CodexNetMon.Lost ? ":(" : lastA + " ms"); + using (var f = new Font("Segoe UI", 13f, GraphicsUnit.Pixel)) + { + float bl = topY - 8; + using (var b = new SolidBrush(Mul(lastN == CodexNetMon.Lost ? Red : Green, a))) + Text(g, tn, f, b, colX, bl); + float wN = g.MeasureString(tn, f, PointF.Empty, StringFormat.GenericTypographic).Width; + using (var b = new SolidBrush(Mul(Dim, a * 0.7f))) + Text(g, "·", f, b, colX + wN + 6, bl); + using (var b = new SolidBrush(Mul(lastA == CodexNetMon.Lost ? Red : Blue, a))) + Text(g, ta, f, b, colX + wN + 18, bl); + } + + _hover = (net, api, colX + 2f, slot, first, count, topY, topY + colH, colX + colW); + } + + private void DrawNetHover(Graphics g, float a) + { + if (_hover is not { } hv) return; + var (net, api, x0, step, first, count, top, bottom, right) = hv; + var m = WidgetInput.Mouse; + if (!WidgetInput.Over || m.X < x0 || m.X > right || m.Y < top - 10 || m.Y > bottom + 10) return; + if (count <= 0) return; + int rel = step > 0 ? (int)((m.X - x0) / step) : 0; + int idx = first + Math.Clamp(rel, 0, count - 1); + int vN = net[idx], vA = api[idx]; + if (vN == CodexNetMon.Empty && vA == CodexNetMon.Empty) return; + + float gx = x0 + (idx - first) * step; + using (var guide = new Pen(Mul(White, a * 0.30f), 1f) { DashStyle = DashStyle.Dot }) + g.DrawLine(guide, gx, top, gx, bottom); + + int lostN = 0, cntN = 0, lostA = 0, cntA = 0; + for (int i = 0; i < net.Length; i++) + { + if (net[i] != CodexNetMon.Empty) { cntN++; if (net[i] == CodexNetMon.Lost) lostN++; } + if (api[i] != CodexNetMon.Empty) { cntA++; if (api[i] == CodexNetMon.Lost) lostA++; } + } + string F(int v) => v == CodexNetMon.Lost ? ":(" : v == CodexNetMon.Empty ? "–" : $"{v} ms"; + var lines = new List<(string t, Color c)> + { + ($"{Fx.NetLabel} {F(vN)} {Fx.ApiLabel} {F(vA)}", White), + ($"{Fx.LossLabel} {Fx.NetLabel} {lostN}/{cntN} · {Fx.ApiLabel} {lostA}/{cntA}", Dim), + ("google.com · chatgpt.com", Dim), + }; + if (vA == CodexNetMon.Lost && vN >= 0) lines.Add(("OpenAI's side :(", Amber)); + else if (vN == CodexNetMon.Lost) lines.Add(("your internet :(", Red)); + + using var f2 = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + float bw2 = 0; + foreach (var l in lines) bw2 = Math.Max(bw2, g.MeasureString(l.t, f2).Width); + bw2 += 16; + float bh2 = lines.Count * 15 + 10; + float bx = Math.Clamp(gx - bw2 / 2f, Pad, right - bw2); + float by = bottom + 8; + if (by + bh2 > 214) by = top - bh2 - 8; + using (var path = Rounded(new RectangleF(bx, by, bw2, bh2), 7)) + { + using (var bg = new SolidBrush(Mul(Color.FromArgb(255, 16, 16, 18), a))) g.FillPath(bg, path); + using (var pen = new Pen(Mul(Track, a), 1f)) g.DrawPath(pen, path); + } + for (int i = 0; i < lines.Count; i++) + using (var b = new SolidBrush(Mul(lines[i].c, a))) + g.DrawString(lines[i].t, f2, b, bx + 8, by + 5 + i * 15); + } + + private static int LastSample(int[] s) + { + for (int i = s.Length - 1; i >= 0; i--) if (s[i] != CodexNetMon.Empty) return s[i]; + return CodexNetMon.Empty; + } + + private static RectangleF CancelRect(int w, int h) => new(42, 16, 34, 34); + + private static RectangleF RefreshRect(int w, int h) => new(RightEdge - 210, 22, 210, 20); + + private static string AgeText(TimeSpan d) => + d.TotalMinutes < 1 ? "just now" + : d.TotalHours < 1 ? $"{(int)d.TotalMinutes}m ago" + : d.TotalDays < 1 ? $"{(int)d.TotalHours}h ago" + : $"{(int)d.TotalDays}d ago"; + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + => new[] + { + (CancelRect(w, h), (Action)(_ => { if (CanCancel) _cancel(); })), + (RefreshRect(w, h), (Action)(_ => { _store.ForceRefresh(); CodexLimits.ForceRefresh(); })), + }; + + private static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = Math.Min(radius * 2, Math.Min(r.Width, r.Height)); + var p = new GraphicsPath(); + if (d <= 0) { p.AddRectangle(r); return p; } + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); + + private static double ContextFrac(CodexSnapshot? st) => + st is null || st.ContextMax <= 0 ? 0 : Math.Clamp((double)st.ContextUsed / st.ContextMax, 0, 1); + + private static Color StateColor(string? state) => state switch + { + "working" => Green, + "compacting" => Blue, + "waiting_input" => Amber, + _ => Color.FromArgb(140, 255, 255, 255), + }; + + private static float UsageFrac() + => CodexLimits.PrimaryFrac >= 0 ? CodexLimits.PrimaryFrac + : CodexLimits.SecondaryFrac >= 0 ? CodexLimits.SecondaryFrac : 0f; + + private static bool RingIsTheMessage(CodexSnapshot? st) + => CodexNetMon.ApiDown || CodexNetMon.NetDown || LimitHit || Compacting(st); + + private static Color RingBase(CodexSnapshot? st, string? tool) + => CodexNetMon.ApiDown || CodexNetMon.NetDown ? Red + : LimitHit ? White + + : st?.State == "waiting_input" ? Fx.SlotColor("asking") + : Compacting(st) ? Blue + : JustCompacted(st) ? Mint + : Shown(st) == "working" ? Fx.SlotColor(ToolSlot(tool)) + : White; + + private Color RingColor(CodexSnapshot? st) + { + var tool = Glow(st); + var b = RingBase(st, tool); + if (RingIsTheMessage(st)) return b; + bool hueIsFree = st?.State != "waiting_input" + && (Shown(st) != "working" || string.IsNullOrEmpty(tool)); + return Fx.MoodRing(b, Mood(st), hueIsFree); + } + + private static string Pct(float f) => $"{(int)Math.Round(f * 100)}%"; + + private static Color LerpC(Color a, Color b, float t) => Color.FromArgb( + (int)(a.A + (b.A - a.A) * t), (int)(a.R + (b.R - a.R) * t), + (int)(a.G + (b.G - a.G) * t), (int)(a.B + (b.B - a.B) * t)); + + private static Color UsageColor(float f) => Fx.UsageColor(f); + + private static string ResetIn(DateTimeOffset r) + { + if (r == default) return ""; + var d = r - DateTimeOffset.UtcNow; + if (d.TotalSeconds <= 0) return "now"; + if (d.TotalDays >= 1) return $"{(int)d.TotalDays}d {d.Hours}h"; + if (d.TotalHours >= 1) return $"{(int)d.TotalHours}h {d.Minutes}m"; + return $"{d.Minutes}m"; + } + + internal static string DisplayText(string state, string? tool, bool apiDown, bool netDown) => + netDown ? "net error :(" : apiDown ? "api error :(" : state switch + { + "working" => ToolVerb(tool, new MoodContext()), + "compacting" => "compacting…", + "waiting_input" => "your move ;)", + _ => Moods.Line("idle"), + }; + + private static bool LimitHit => CodexLimits.PrimaryFrac >= 0.99f || CodexLimits.SecondaryFrac >= 0.99f; + + private static string LimitReset() + { + var r = ResetIn(CodexLimits.PrimaryFrac >= 0.99f ? CodexLimits.PrimaryReset : CodexLimits.SecondaryReset); + return r.Length > 0 ? "back in " + r : ""; + } + + private static string IdleMood(CodexSnapshot? st, in MoodContext ctx) => + CodexNetMon.NetDown ? Moods.Line("offline") + : CodexNetMon.ApiDown ? Moods.Line("apiDown") + : JustCompacted(st) ? Moods.Line("compacted") + : CodexLimits.PrimaryFrac >= 0.95f ? Moods.Line("outOfCredit") + : Moods.Line("idle", ctx); + + private static bool JustCompacted(CodexSnapshot? st) => + st?.CompactedAt is { } t && DateTimeOffset.UtcNow - t < TimeSpan.FromSeconds(20); + + private static string? OutageText() => + CodexNetMon.NetDown ? Moods.Line("netError") : CodexNetMon.ApiDown ? Moods.Line("apiError") : null; + + internal static string? ToolSlot(string? tool) => tool switch + { + "exec" or "shell" or "shell_command" or "local_shell" or "exec_command" or "container" => "running", + "apply_patch" or "edit" or "write_file" => "patching", + "read_file" or "view" or "cat" => "reading", + "grep" or "rg" or "find" or "list_dir" or "ls" => "digging", + "web_search" or "search" => "searching", + "browser" or "fetch" or "open_url" => "fetching", + "view_image" or "screenshot" => "peeking", + "update_plan" or "plan" => "plotting", + "spawn" or "agent" or "subagent" or "thread_spawn" => "delegating", + "request_user_input" or "ask" => "asking", + "wait" or "poll" or "watch" => "watching", + null or "" => "unknown", + _ when tool.StartsWith("mcp", StringComparison.OrdinalIgnoreCase) => "consulting", + _ => null, + }; + + private static string ToolVerb(string? tool, in MoodContext ctx) + => ToolSlot(tool) is { } slot ? Moods.Line(slot, ctx) : Moods.PrettyTool(tool); + + private static TimeSpan? Running(CodexSnapshot? st) => + st?.StartedAt is { } t ? DateTimeOffset.UtcNow - t : null; + + private const int AfterglowMs = 9_000; + private string? _glowTool; + private DateTimeOffset? _glowTurn; + private long _glowAt; + + private string? Glow(CodexSnapshot? st) + { + if (st?.StartedAt != _glowTurn) { _glowTurn = st?.StartedAt; _glowTool = null; } + if (st?.CurrentTool is { Length: > 0 } cur) + { + _glowTool = cur; + _glowAt = Environment.TickCount64; + return cur; + } + if (Shown(st) != "working") { _glowTool = null; return null; } + return Environment.TickCount64 - _glowAt <= AfterglowMs ? _glowTool : null; + } + + private MoodContext Mood(CodexSnapshot? st) => new( + Running(st), (float)ContextFrac(st), UsageFrac(), + st?.PromptTokens ?? 0, ToolRuns(st), DateTime.Now.Hour); + + private DateTimeOffset? _runsTurn; + private string? _runsTool; + private int _runs; + + private int ToolRuns(CodexSnapshot? st) + { + var stamp = st?.StartedAt; + if (stamp != _runsTurn) { _runsTurn = stamp; _runsTool = null; _runs = 0; } + var tool = st?.CurrentTool; + if (!string.IsNullOrEmpty(tool) && tool != _runsTool) { _runsTool = tool; _runs++; } + return _runs; + } + + private static string Elapsed(CodexSnapshot? st) + { + if ((Shown(st) != "working" && !Compacting(st)) || st?.StartedAt is not { } t) return ""; + var d = DateTimeOffset.UtcNow - t; + if (d.TotalSeconds < 1) return ""; + return d.TotalMinutes >= 1 ? $"{(int)d.TotalMinutes}m {d.Seconds}s" : $"{d.Seconds}s"; + } +} diff --git a/src/Halo.App/Widgets/DownloadWidget.cs b/src/Halo.App/Widgets/DownloadWidget.cs new file mode 100644 index 0000000..2d04c1a --- /dev/null +++ b/src/Halo.App/Widgets/DownloadWidget.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; + +namespace Halo.Widgets; + +internal sealed class DownloadWidget : IWidget +{ + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Track = Color.FromArgb(46, 255, 255, 255); + private static readonly Color Blue = Color.FromArgb(120, 170, 255); + private static readonly FontFamily Fluent = new("Segoe Fluent Icons"); + + public DownloadWidget() => Downloads.Poke(); + + public string Icon => ""; + private static string? _icoFile; + private static Bitmap? _icoCache; + + private static Bitmap? Ico() + { + if (Downloads.IconFile is { } f) + { + if (f != _icoFile) { _icoCache?.Dispose(); _icoCache = LoadFile(f); _icoFile = f; } + if (_icoCache != null) return _icoCache; + } + return Downloads.IsStore && Downloads.ExePath is { } aumid + ? Halo.Notifications.ShellIcon.ForAumid(aumid) + : AppIcon.ForAumid(Downloads.ExePath); + } + private static Bitmap? LoadFile(string f) + { + try { using var t = new Bitmap(f); return new Bitmap(t); } catch { return null; } + } + public Bitmap? IconImage => Ico(); + public bool IsActive => Downloads.Name != null; + + public float RingProgress => Downloads.Name == null || Downloads.Installing || Downloads.Waiting || Downloads.NoPct + ? -1f : Math.Clamp(Downloads.Percent / 100f, 0f, 1f); + + private static bool Spinning => Downloads.Installing || Downloads.Waiting || (Downloads.NoPct && !Downloads.Paused); + public int Version => Downloads.Version + (Spinning ? (int)(Environment.TickCount64 / 60) : 0); + public bool Animating => Spinning; + public Color? Ring => Downloads.Name == null ? null : Accent(); + + private static Color Accent() + { + var a = Fx.AccentOf(Ico()); + return a == Fx.White ? Blue : a; + } + + private const float ArtX = 26, ArtY = 26, ArtSize = 132; + private static RectangleF[] CtlRects(int n) + { + const float size = 40, gap = 14, y = 158; + float x0 = ArtX + ArtSize + 24; + var r = new RectangleF[n]; + for (int i = 0; i < n; i++) r[i] = new RectangleF(x0 + i * (size + gap), y, size, size); + return r; + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> CollapsedButtons(int w, int h) + => Array.Empty<(RectangleF, Action)>(); + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + { + var hits = new List<(RectangleF, Action)>(); + int n = Downloads.Count; + if (Downloads.HasMore) hits.Add((MenuRect(w), _ => _menuOpen = !_menuOpen)); + if (_menuOpen && Downloads.HasMore) + { + int top = MenuTop(n), rows = Math.Min(n - top, MaxRows); + for (int v = 0; v < rows; v++) + { + int idx = top + v; + hits.Add((RowRect(w, n, v), _ => { Downloads.Select(idx); _menuOpen = false; })); + } + + hits.Add((new RectangleF(0, 0, w, h), _ => _menuOpen = false)); + return hits; + } + foreach (var c in Chips()) { var act = c.Click; hits.Add((c.Rect, _ => act())); } + return hits; + } + + private readonly record struct Chip(RectangleF Rect, int Glyph, bool Danger, bool Stop, Action Click); + + private static Chip[] Chips() + { + var row = Row(Downloads.Name != null, Downloads.IsStore, Downloads.CanControl, + Downloads.Hwnd != IntPtr.Zero, Downloads.FilePath is { Length: > 0 }); + var rects = CtlRects(row.Length); + var chips = new Chip[row.Length]; + for (int i = 0; i < row.Length; i++) chips[i] = Make(rects[i], row[i]); + return chips; + } + + internal enum DlCtl { PauseResume, StoreCancel, Reveal, Stop, ShowInFolder, RevealOwner, Cancel } + + internal static DlCtl[] Row(bool named, bool store, bool canControl, bool hasWindow, bool hasPath) + { + if (!named) return Array.Empty(); + if (store && canControl) return new[] { DlCtl.PauseResume, DlCtl.StoreCancel }; + if (hasWindow) return new[] { DlCtl.Reveal, DlCtl.Stop }; + if (hasPath) return new[] { DlCtl.ShowInFolder, DlCtl.RevealOwner, DlCtl.Cancel }; + return Array.Empty(); + } + + private static Chip Make(RectangleF r, DlCtl c) => c switch + { + DlCtl.PauseResume => new Chip(r, Downloads.Paused ? 0xE768 : 0xE769, false, false, + () => { if (Downloads.Paused) Downloads.StoreResume(); else Downloads.StorePause(); }), + DlCtl.StoreCancel => new Chip(r, 0xE711, true, false, Downloads.StoreCancel), + DlCtl.Reveal => new Chip(r, 0xE838, false, false, Downloads.Reveal), + DlCtl.Stop => new Chip(r, 0, false, true, Downloads.StopProcess), + DlCtl.ShowInFolder => new Chip(r, 0xE838, false, false, Downloads.ShowInFolder), + DlCtl.RevealOwner => new Chip(r, 0xE7C4, false, false, Downloads.RevealOwner), + + _ => new Chip(r, 0xE711, true, false, Downloads.CancelDownload), + }; + + private static float _fracShown = -1f; + private static string? _lastName; + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) return; + string? name = Downloads.Name; + if (name == null) return; + bool indeterminate = Downloads.Installing || Downloads.Waiting || (Downloads.NoPct && !Downloads.Paused); + bool paused = Downloads.Paused; + int pct = Math.Clamp(Downloads.Percent, 0, 100); + long done = Downloads.Downloaded, tot = Downloads.Total; + var icon = Ico(); + var accent = icon != null ? Accent() : Blue; + float pulse = 0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 480f); + + g.SmoothingMode = SmoothingMode.AntiAlias; + var oldHint = g.TextRenderingHint; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + + Fx.Glow(g, w, h, fade * (indeterminate ? 0.55f + 0.45f * pulse : 1f), + ArtX + ArtSize / 2f, h / 2f, w * 0.85f, h * 1.2f, 34, accent); + DrawArt(g, icon, fade); + + float tx = ArtX + ArtSize + 24, tw = w - tx - MenuSlot - 26; + using var titleF = new Font("Segoe UI Semibold", 23f, GraphicsUnit.Pixel); + using var metaF = new Font("Segoe UI", 14f, GraphicsUnit.Pixel); + using var smallF = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + + float y = ArtY + 4; + using (var tb = new SolidBrush(Mul(White, fade))) + DrawEllipsized(g, name, titleF, tb, tx, y, tw, 30); + y += 32; + + string state = Downloads.Waiting ? "Waiting…" : Downloads.Installing ? "Installing…" + : paused ? "Paused" : "Downloading"; + string meta = state; + + if (Downloads.NoBytes) { } + else if (done > 1_048_576 && tot > 1_048_576) meta += $" · {Bytes(done)} / {Bytes(tot)} · {pct}%"; + else if (done > 1_048_576) meta += $" · {Bytes(done)}"; + using (var mb = new SolidBrush(Mul(Dim, fade))) + DrawEllipsized(g, meta, metaF, mb, tx, y, tw, 20); + y += 30; + + float bh = 6; + Fill(g, tx, y, tw, bh, Mul(Track, fade), bh / 2); + if (indeterminate) + { + float seg = tw * 0.34f, sx = tx + (tw - seg) * (0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 700f)); + Fill(g, sx, y, seg, bh, Mul(accent, fade * (0.5f + 0.5f * pulse)), bh / 2); + } + else + { + float frac = tot > 1_048_576 ? Math.Clamp(done / (float)tot, 0f, 1f) : pct / 100f; + if (name != _lastName) { _lastName = name; _fracShown = frac; } + _fracShown = _fracShown < 0 ? frac : _fracShown + (frac - _fracShown) * 0.18f; + if (Math.Abs(frac - _fracShown) < 0.002f) _fracShown = frac; + if (_fracShown > 0) Fill(g, tx, y, tw * _fracShown, bh, Mul(paused ? Dim : accent, fade), bh / 2); + } + y += bh + 10; + + if (Downloads.FilePath is { Length: > 0 } fp) + { + string? dir = null; + try { dir = System.IO.Path.GetDirectoryName(fp); } catch { } + if (!string.IsNullOrEmpty(dir)) + using (var pb = new SolidBrush(Mul(Color.FromArgb(115, 255, 255, 255), fade))) + using (var psf = new StringFormat(StringFormat.GenericTypographic) + { Trimming = StringTrimming.EllipsisPath, FormatFlags = StringFormatFlags.NoWrap }) + g.DrawString(dir, smallF, pb, new RectangleF(tx, y, tw, 18), psf); + } + + DrawControls(g, fade); + DrawMenuSlot(g, w, fade); + DrawMenuList(g, w, h, fade); + g.TextRenderingHint = oldHint; + } + + private const float MenuSlot = 44; + + internal static RectangleF MenuRect(int w) => new(w - MenuSlot - 8, 22, 34, 34); + + private static bool _menuOpen; + private const float MenuW = 252f, RowH = 32f, MenuPad = 7f, MenuR = 15f; + private const int MaxRows = 4; + + internal static RectangleF MenuListRect(int w, int n) + => new(w - MenuW - 8, MenuRect(w).Bottom + 8, MenuW, Math.Min(n, MaxRows) * RowH + MenuPad * 2); + + private static int MenuTop(int n) => MenuTop(n, Downloads.SelectedIndex, MaxRows); + + internal static int MenuTop(int n, int selected, int maxRows) + => n <= maxRows ? 0 : Math.Clamp(selected - maxRows + 1, 0, n - maxRows); + + private static RectangleF RowRect(int w, int n, int visible) + { + var l = MenuListRect(w, n); + return new RectangleF(l.X + MenuPad, l.Y + MenuPad + visible * RowH, l.Width - MenuPad * 2, RowH); + } + + private void DrawMenuList(Graphics g, int w, int h, float fade) + { + if (!_menuOpen || !Downloads.HasMore) return; + var items = Downloads.Items; + int n = items.Count; + if (n == 0) return; + + using (var scrim = new SolidBrush(Mul(Color.FromArgb(120, 0, 0, 0), fade))) + g.FillRectangle(scrim, 0, 0, w, h); + var l = MenuListRect(w, n); + + for (int i = 6; i >= 1; i--) + { + var s = RectangleF.Inflate(l, i, i); + s.Y += 2f; + using var sp = Fx.Rounded(s, MenuR + i); + using var pen = new Pen(Mul(Color.FromArgb(11, 0, 0, 0), fade), 2f); + g.DrawPath(pen, sp); + } + + using (var bg = new SolidBrush(Mul(Color.FromArgb(232, 22, 22, 26), fade))) + using (var p = Fx.Rounded(l, MenuR)) + g.FillPath(bg, p); + + using (var pen = new Pen(Mul(Color.FromArgb(52, 255, 255, 255), fade), 1f)) + using (var p = Fx.Rounded(RectangleF.Inflate(l, -0.5f, -0.5f), MenuR - 0.5f)) + g.DrawPath(pen, p); + using (var pen = new Pen(Mul(Color.FromArgb(30, 0, 0, 0), fade), 1f)) + using (var p = Fx.Rounded(RectangleF.Inflate(l, 0.5f, 0.5f), MenuR + 0.5f)) + g.DrawPath(pen, p); + + using var f = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var bold = new Font("Segoe UI Semibold", 13f, GraphicsUnit.Pixel); + var accent = Accent(); + int top = MenuTop(n), sel = Downloads.SelectedIndex, rows = Math.Min(n - top, MaxRows); + for (int v = 0; v < rows; v++) + { + int idx = top + v; + var r = RowRect(w, n, v); + bool cur = idx == sel, hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + if (cur || hov) + using (var hb = new SolidBrush(Mul(Color.FromArgb(cur ? 34 : 18, 255, 255, 255), fade))) + using (var p = Fx.Rounded(r, 10f)) + g.FillPath(hb, p); + + if (cur) + using (var ab = new SolidBrush(Mul(accent, fade))) + using (var p = Fx.Rounded(new RectangleF(r.X + 4, r.Y + RowH * 0.26f, 3f, RowH * 0.48f), 1.5f)) + g.FillPath(ab, p); + + var it = items[idx]; + + string tail = it.NoPct ? Bytes(it.Downloaded) : $"{it.Percent}%"; + var tsz = g.MeasureString(tail, f); + using (var tb = new SolidBrush(Mul(cur ? Dim : Color.FromArgb(112, 255, 255, 255), fade))) + g.DrawString(tail, f, tb, r.Right - tsz.Width - 10, r.Y + (RowH - tsz.Height) / 2f); + using (var nb = new SolidBrush(Mul(cur ? White : Dim, fade))) + DrawEllipsized(g, it.Name, cur ? bold : f, nb, r.X + 14, r.Y + (RowH - 17) / 2f, + r.Width - tsz.Width - 32, 17); + } + } + + private static void DrawMenuSlot(Graphics g, int w, float fade) + { + if (!Downloads.HasMore) { _menuOpen = false; return; } + var r = MenuRect(w); + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + bool lit = hov || _menuOpen; + using (var bg = new SolidBrush(Mul(Color.FromArgb(lit ? 42 : 24, 255, 255, 255), fade))) + using (var p = Fx.Rounded(r, 10f)) + g.FillPath(bg, p); + using var b = new SolidBrush(Mul(lit ? White : Dim, fade)); + float bw = r.Width * 0.44f, x = r.X + (r.Width - bw) / 2f; + for (int i = 0; i < 3; i++) g.FillRectangle(b, x, r.Y + 11 + i * 6, bw, 2f); + } + + private void DrawControls(Graphics g, float fade) + { + foreach (var c in Chips()) + { + if (c.Stop) DrawStop(g, c.Rect, fade); + else DrawCtl(g, c.Rect, c.Glyph, fade, c.Danger); + } + } + + private static void DrawArt(Graphics g, Bitmap? icon, float fade) + => IconTile(g, new RectangleF(ArtX, ArtY, ArtSize, ArtSize), ArtSize * 0.24f, icon, fade, 46f, + border: icon == null); + + private static void IconTile(Graphics g, RectangleF box, float radius, Bitmap? icon, float fade, float glyphPx, bool border) + { + using var path = Fx.Rounded(box, radius); + g.SmoothingMode = SmoothingMode.AntiAlias; + if (icon != null) + { + int s = Math.Max(1, (int)Math.Ceiling(box.Width)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + sg.SmoothingMode = SmoothingMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = fade }); + int side = Math.Min(icon.Width, icon.Height); + sg.DrawImage(icon, new Rectangle(0, 0, s, s), + (icon.Width - side) / 2, (icon.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(box.X, box.Y); + g.FillPath(tb, path); + } + else + { + using var gb = new SolidBrush(Mul(Track, fade)); + g.FillPath(gb, path); + DrawGlyph(g, box, ((char)0xE896).ToString(), glyphPx, fade); + } + if (border) + { + using var pen = new Pen(Mul(Color.FromArgb(28, 255, 255, 255), fade), 1f); + g.DrawPath(pen, path); + } + } + + private static void DrawCollapsedIcon(Graphics g, Bitmap? icon, float x, float y, float sz, float fade) + => IconTile(g, new RectangleF(x, y, sz, sz), sz * 0.28f, icon, fade, sz * 0.5f, border: false); + + private static string Bytes(long b) + { + if (b <= 0) return "0 MB"; + double mb = b / 1048576.0; + return mb >= 1024 ? $"{mb / 1024:0.0} GB" : $"{mb:0} MB"; + } + + private static readonly Color Ctl = Color.FromArgb(255, 255, 255, 255); + + private void DrawCtl(Graphics g, RectangleF r, int glyph, float fade, bool danger) + { + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + var tint = danger ? Red : Ctl; + using (var bg = new SolidBrush(Mul(Color.FromArgb(hov ? 58 : 34, tint), fade))) + g.FillEllipse(bg, r); + using (var pen = new Pen(Mul(Color.FromArgb(hov ? 70 : 40, tint), fade), 1f)) + g.DrawEllipse(pen, r); + DrawGlyph(g, r, ((char)glyph).ToString(), r.Width * 0.40f, fade * (hov ? 1f : 0.85f), danger ? tint : White); + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + _menuOpen = false; + string? name = Downloads.Name; + if (name == null) return; + var icon = Ico(); + var accent = icon != null ? Accent() : Blue; + g.SmoothingMode = SmoothingMode.AntiAlias; + float sz = h - 14f, ix = 9, iy = (h - sz) / 2f; + float tx = ix + sz + 12; + + bool breathe = Downloads.Waiting || (Downloads.NoPct && !Downloads.Paused && !Downloads.Installing); + if (breathe) + { + float pulse = 0.5f - 0.5f * MathF.Cos(Environment.TickCount % 2400 / 2400f * MathF.Tau); + using (var pb = new SolidBrush(Mul(accent, fade * (0.05f + 0.12f * pulse)))) + using (var pp = Fx.PillPath(w, h, h / 2f)) + g.FillPath(pb, pp); + DrawCollapsedIcon(g, icon, ix, iy, sz, fade); + DrawCountBadge(g, ix, iy, sz, fade, Downloads.Count); + using var nf = new Font("Segoe UI Semibold", 14f, GraphicsUnit.Pixel); + using var nb = new SolidBrush(Mul(White, fade)); + float right = w - tx - 14; + + if (!Downloads.Waiting && !Downloads.NoBytes && Downloads.Downloaded > 0) + { + string got = Bytes(Downloads.Downloaded); + using var sf2 = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var sb2 = new SolidBrush(Mul(Dim, fade)); + var gsz = g.MeasureString(got, sf2); + g.DrawString(got, sf2, sb2, w - gsz.Width - 14, (h - gsz.Height) / 2f); + right -= gsz.Width + 8; + } + DrawEllipsized(g, name, nf, nb, tx, (h - 18f) / 2f, right, 18); + return; + } + + DrawCollapsedIcon(g, icon, ix, iy, sz, fade); + float by = h / 2f - 3, bh = 6; + if (Downloads.Installing) + { + float p = 0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 480f); + float bw = w - tx - 16; + Fill(g, tx, by, bw, bh, Track, bh / 2); + float seg = bw * 0.38f, sx = tx + (bw - seg) * (0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 700f)); + Fill(g, sx, by, seg, bh, Mul(accent, 0.5f + 0.5f * p), bh / 2); + return; + } + DrawPillProgress(g, w, h, fade, Math.Clamp(Downloads.Percent, 0, 100), accent, + Downloads.Paused, ix + sz); + } + + private static void DrawPillProgress(Graphics g, int w, int h, float fade, int pct, Color accent, + bool paused, float iconRight) + { + var bar = paused ? Dim : accent; + + Fx.PillBar(g, w, h, fade, pct / 100f, bar, 1f, alive: !paused); + + float sz = h - 14f; + DrawCollapsedIcon(g, Ico(), 9, (h - sz) / 2f, sz, fade); + if (paused) DrawPausedBadge(g, 9, (h - sz) / 2f, sz, fade); + DrawCountBadge(g, 9, (h - sz) / 2f, sz, fade, Downloads.Count); + + long done = Downloads.Downloaded, tot = Downloads.Total; + + var oldHint = g.TextRenderingHint; + + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + using var f = new Font("Segoe UI Semibold", 14f, GraphicsUnit.Pixel); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }; + + float left = iconRight + 8f, right = w - 12f; + + string text = $"{pct}%"; + foreach (var candidate in new[] + { + done > 1_048_576 && tot > 1_048_576 ? $"{Bytes(done)} / {Bytes(tot)} · {pct}%" : null, + done > 1_048_576 ? $"{Bytes(done)} · {pct}%" : null, + }) + { + if (candidate == null) continue; + if (g.MeasureString(candidate, f, int.MaxValue, sf).Width <= right - left) { text = candidate; break; } + } + var zone = new RectangleF(left, -Fx.CenterLift(f), right - left, h); + using (var shadow = new SolidBrush(Mul(Color.FromArgb(110, 0, 0, 0), fade))) + g.DrawString(text, f, shadow, new RectangleF(zone.X + 0.6f, zone.Y + 0.6f, zone.Width, zone.Height), sf); + using (var nb = new SolidBrush(Mul(White, fade))) + g.DrawString(text, f, nb, zone, sf); + g.TextRenderingHint = oldHint; + + } + + private static void DrawCountBadge(Graphics g, float x, float y, float sz, float fade, int n) + { + if (n < 2) return; + float d = sz * 0.60f, bx = x + sz - d + 1f, by = y - 1f; + using (var shade = new SolidBrush(Mul(Color.FromArgb(215, 12, 12, 14), fade))) + g.FillEllipse(shade, bx, by, d, d); + using (var ring = new Pen(Mul(Color.FromArgb(190, 255, 255, 255), fade), 1.1f)) + g.DrawEllipse(ring, bx, by, d, d); + using var f = new Font("Segoe UI Semibold", d * 0.62f, GraphicsUnit.Pixel); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }; + using var b = new SolidBrush(Mul(White, fade)); + g.DrawString(n > 9 ? "9+" : n.ToString(), f, b, + new RectangleF(bx, by - Fx.CenterLift(f), d, d), sf); + } + + private static void DrawPausedBadge(Graphics g, float x, float y, float sz, float fade) + { + float d = sz * 0.62f, bx = x + sz - d + 2f, by = y + sz - d + 2f; + using (var shade = new SolidBrush(Mul(Color.FromArgb(190, 12, 12, 14), fade))) + g.FillEllipse(shade, bx, by, d, d); + using (var ring = new Pen(Mul(Color.FromArgb(210, 255, 255, 255), fade), 1.2f)) + g.DrawEllipse(ring, bx, by, d, d); + + float bw = d * 0.16f, bh = d * 0.42f, gap = d * 0.14f; + float cx = bx + d / 2f, cy = by + d / 2f; + using var b = new SolidBrush(Mul(White, fade)); + g.FillRectangle(b, cx - gap / 2f - bw, cy - bh / 2f, bw, bh); + g.FillRectangle(b, cx + gap / 2f, cy - bh / 2f, bw, bh); + } + + private static readonly Color Red = Color.FromArgb(255, 120, 110); + private static void DrawStop(Graphics g, RectangleF r, float fade) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + using (var bg = new SolidBrush(Mul(Color.FromArgb(hov ? 60 : 38, 255, 120, 110), fade))) + g.FillEllipse(bg, r); + float s = r.Width * 0.34f; + var sq = new RectangleF(r.X + (r.Width - s) / 2f, r.Y + (r.Height - s) / 2f, s, s); + using var b = new SolidBrush(Mul(Red, fade * (hov ? 1f : 0.85f))); + using var p = Fx.Rounded(sq, s * 0.22f); + g.FillPath(b, p); + } + + private static Color Mul(Color c, float a) => Color.FromArgb((int)(c.A * a), c.R, c.G, c.B); + + private static void Fill(Graphics g, float x, float y, float w, float h, Color c, float r = 0) + { + if (w <= 0.5f) return; + using var b = new SolidBrush(c); + if (r <= 0) { g.FillRectangle(b, x, y, w, h); return; } + using var p = Fx.Rounded(new RectangleF(x, y, w, h), r); + g.FillPath(b, p); + } + + private static void DrawEllipsized(Graphics g, string s, Font f, Brush b, float x, float y, float w, float h) + { + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap }; + g.DrawString(s, f, b, new RectangleF(x, y, w, h), sf); + } + + private static void DrawGlyph(Graphics g, RectangleF r, string glyph, float px, float fade, Color? tint = null) + { + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(glyph, Fluent, (int)FontStyle.Regular, px, PointF.Empty, sf); + path.Flatten(); + var bnd = path.GetBounds(); + if (bnd.Width <= 0 || bnd.Height <= 0) return; + using var m = new Matrix(); + m.Translate(MathF.Round(r.X + (r.Width - bnd.Width) / 2f - bnd.X), + MathF.Round(r.Y + (r.Height - bnd.Height) / 2f - bnd.Y)); + path.Transform(m); + using var br = new SolidBrush(Mul(tint ?? White, fade * 0.9f)); + g.FillPath(br, path); + } +} diff --git a/src/Halo.App/Widgets/Downloaders.cs b/src/Halo.App/Widgets/Downloaders.cs new file mode 100644 index 0000000..a3ac9f3 --- /dev/null +++ b/src/Halo.App/Widgets/Downloaders.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Halo.Widgets; + +internal static class Downloaders +{ + private const int MaxEntries = 24; + private static readonly object _lock = new(); + private static readonly Dictionary _dirs = new(StringComparer.OrdinalIgnoreCase); + private static bool _loaded; + + private static readonly string[] Ignore = + { "halo.app", "halo.hooks", "msiexec", "trustedinstaller", "wuauclt", "svchost", "explorer" }; + + private static string Dir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo"); + private static string StatePath => Path.Combine(Dir, "downloaders.tsv"); + + public static IEnumerable Directories() + { + Load(); + lock (_lock) return new List(_dirs.Keys); + } + + public static string? AppFor(string? directory) + { + if (string.IsNullOrEmpty(directory)) return null; + Load(); + lock (_lock) return _dirs.TryGetValue(directory!, out var app) ? app : null; + } + + public static void Learn(int pid, string? directory) + { + if (pid == 0 || string.IsNullOrEmpty(directory)) return; + string app; + try { using var p = System.Diagnostics.Process.GetProcessById(pid); app = p.ProcessName; } + catch { return; } + foreach (var bad in Ignore) + if (app.Equals(bad, StringComparison.OrdinalIgnoreCase)) return; + + Load(); + bool added; + lock (_lock) + { + if (_dirs.TryGetValue(directory!, out var known) && known.Equals(app, StringComparison.OrdinalIgnoreCase)) + return; + if (_dirs.Count >= MaxEntries && !_dirs.ContainsKey(directory!)) return; + _dirs[directory!] = app; + added = true; + } + if (added) Append(directory!, app); + } + + private static void Load() + { + lock (_lock) + { + if (_loaded) return; + _loaded = true; + try + { + if (!File.Exists(StatePath)) return; + foreach (var line in File.ReadAllLines(StatePath)) + { + int tab = line.IndexOf('\t'); + if (tab <= 0) continue; + string dir = line.Substring(0, tab), app = line.Substring(tab + 1); + if (dir.Length > 0 && Directory.Exists(dir)) _dirs[dir] = app; + } + } + catch { } + } + } + + private static void Append(string directory, string app) + { + try { Directory.CreateDirectory(Dir); File.AppendAllText(StatePath, $"{directory}\t{app}\r\n"); } + catch { } + } +} diff --git a/src/Halo.App/Widgets/Downloads.cs b/src/Halo.App/Widgets/Downloads.cs new file mode 100644 index 0000000..a114450 --- /dev/null +++ b/src/Halo.App/Widgets/Downloads.cs @@ -0,0 +1,538 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Halo.Interop; + +namespace Halo.Widgets; + +internal static class Downloads +{ + public static volatile string? Name; + public static volatile int Percent; + public static volatile string? ExePath; + public static volatile string? FilePath; + public static volatile int OwnerPid; + + public static volatile int Count; + public static bool HasMore => Count > 1; + public static volatile string? IconFile; + public static volatile bool Installing; + public static volatile bool Waiting; + public static volatile bool Paused; + public static volatile bool IsStore; + public static volatile bool CanControl; + public static volatile bool NoPct; + + public static volatile bool NoBytes; + public static long Downloaded, Total; + public static IntPtr Hwnd; + public static int Version; + + public static void Reveal() + { + var h = Hwnd; + if (h == IntPtr.Zero) return; + try + { + Win32.ShowWindow(h, Win32.SW_RESTORE); + + uint fore = Win32.GetWindowThreadProcessId(Win32.GetForegroundWindow(), out _); + uint self = Win32.GetCurrentThreadId(); + bool attached = fore != 0 && fore != self && Win32.AttachThreadInput(fore, self, true); + Win32.SetForegroundWindow(h); + if (attached) Win32.AttachThreadInput(fore, self, false); + } + catch { } + } + + public static void StopProcess() + { + var h = Hwnd; + if (h == IntPtr.Zero) return; + try + { + Win32.GetWindowThreadProcessId(h, out uint pid); + if (pid == 0) return; + using var p = System.Diagnostics.Process.GetProcessById((int)pid); + p.Kill(entireProcessTree: true); + } + catch { } + } + + public static void StorePause() { if (IsStore) StoreInstall.Pause(); } + public static void StoreResume() { if (IsStore) StoreInstall.Resume(); } + public static void StoreCancel() { if (IsStore) StoreInstall.Cancel(); } + + private static string _lastLog = ""; + internal static void LogState() + { + try + { + string s = $"name='{Name}' store={IsStore} canControl={CanControl} hwnd={(Hwnd != IntPtr.Zero)} exe='{ExePath}' pct={Percent} inst={Installing} wait={Waiting}"; + if (s == _lastLog) return; + _lastLog = s; + System.IO.File.AppendAllText(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData), "Halo", "dl-debug.txt"), + $"{System.DateTime.Now:HH:mm:ss} {s}\r\n"); + } + catch { } + } + + private static Timer? _timer; + private static readonly Regex Pct = new(@"^\s*\[?\s*(\d{1,3})\s*%", RegexOptions.Compiled); + private static readonly StringBuilder Buf = new(512); + private static readonly string[] Browsers = + { "chrome", "msedge", "firefox", "brave", "opera", "vivaldi", "iexplore", "waterfox", "librewolf" }; + + public static void Poke() => _timer ??= new Timer(_ => Scan(), null, 500, 1000); + + internal sealed record DlItem(string Key, string Name, int Percent, long Downloaded, long Total, + bool NoPct, bool NoBytes, bool Paused, bool Installing, bool Waiting, bool IsStore, bool CanControl, + string? ExePath, string? IconFile, string? FilePath, int OwnerPid, IntPtr Hwnd); + + private static DlItem[] _items = Array.Empty(); + public static IReadOnlyList Items => _items; + + private static readonly Dictionary _born = new(StringComparer.Ordinal); + private static string? _selKey; + private static DlItem? _applied; + + public static int SelectedIndex + { + get { var a = _items; for (int i = 0; i < a.Length; i++) if (a[i].Key == _selKey) return i; return 0; } + } + + public static void Select(int index) + { + var a = _items; + if (index < 0 || index >= a.Length) return; + _selKey = a[index].Key; + Apply(a[index]); + } + + internal static void Order(List found, Dictionary born, long now) + { + var keys = new HashSet(StringComparer.Ordinal); + foreach (var i in found) { keys.Add(i.Key); if (!born.ContainsKey(i.Key)) born[i.Key] = now; } + foreach (var k in new List(born.Keys)) if (!keys.Contains(k)) born.Remove(k); + + found.Sort((a, b) => born[a.Key] != born[b.Key] + ? born[a.Key].CompareTo(born[b.Key]) : string.CompareOrdinal(a.Key, b.Key)); + } + + private static void Publish(List found) + { + Order(found, _born, Environment.TickCount64); + _items = found.ToArray(); + Count = _items.Length; + Apply(Pick()); + } + + private static DlItem? Pick() + { + var a = _items; + if (a.Length == 0) { _selKey = null; return null; } + if (_selKey != null) foreach (var i in a) if (i.Key == _selKey) return i; + _selKey = null; + return a[0]; + } + + private static void Apply(DlItem? it) + { + if (it == _applied) return; + _applied = it; + if (it is null) + { + Name = null; Percent = 0; ExePath = null; IconFile = null; Installing = false; Waiting = false; + Paused = false; IsStore = false; CanControl = false; NoPct = false; NoBytes = false; Downloaded = Total = 0; + Hwnd = IntPtr.Zero; FilePath = null; OwnerPid = 0; + Interlocked.Increment(ref Version); + return; + } + Name = it.Name; Percent = it.Percent; Downloaded = it.Downloaded; Total = it.Total; + NoPct = it.NoPct; NoBytes = it.NoBytes; Paused = it.Paused; Installing = it.Installing; Waiting = it.Waiting; + IsStore = it.IsStore; CanControl = it.CanControl; ExePath = it.ExePath; IconFile = it.IconFile; + FilePath = it.FilePath; OwnerPid = it.OwnerPid; Hwnd = it.Hwnd; + Interlocked.Increment(ref Version); + LogState(); + } + + internal static void Scan() + { + var found = new List(); + try + { + + var winPids = new HashSet(); + var winNames = new HashSet(StringComparer.OrdinalIgnoreCase); + Win32.EnumWindows((h, _) => + { + if (!Win32.IsWindowVisible(h)) return true; + int len = Win32.GetWindowTextLengthW(h); + if (len < 3 || len > 400) return true; + Buf.Clear(); + if (Win32.GetWindowTextW(h, Buf, Buf.Capacity) == 0) return true; + string t = Buf.ToString(); + var m = Pct.Match(t); + if (!m.Success) return true; + int p = int.Parse(m.Groups[1].Value); + if (p >= 100) return true; + if (IsBrowser(h)) return true; + string nm = Clean(t, m); + if (!winNames.Add(nm)) return true; + Win32.GetWindowThreadProcessId(h, out uint wp); + winPids.Add(wp); + found.Add(new DlItem("win:" + nm, nm, p, 0, 0, false, false, false, false, false, false, false, + ExeOf(h), null, null, (int)wp, h)); + return true; + }, IntPtr.Zero); + + var ph = StoreInstall.Poll(out string app, out int spct, out long done, out long total); + if (ph != StoreInstall.Phase.None) + found.Add(new DlItem("store:" + app, app, spct, done, total, false, false, + ph == StoreInstall.Phase.Paused, ph == StoreInstall.Phase.Installing, + ph == StoreInstall.Phase.Waiting, true, true, StoreAumid, null, null, 0, IntPtr.Zero)); + + if (GameInstall.Poll(out string gApp, out long gDone, out long gTotal, out bool gStalled)) + found.Add(new DlItem("gdk:" + gApp, gApp, gTotal > 0 ? (int)Math.Clamp(gDone * 100 / gTotal, 0, 99) : 0, + gDone, gTotal, gTotal <= 0, false, gStalled, false, false, true, false, + StoreAumid, GameInstall.LogoPath, null, 0, IntPtr.Zero)); + + if (SteamInstall.Current() is { } steam) + found.Add(new DlItem("steam:" + steam.Name, steam.Name, + (int)Math.Clamp(steam.Done * 100 / Math.Max(steam.Total, 1), 0, 99), + steam.Done, steam.Total, false, false, false, false, false, false, false, + SteamExe(), null, null, 0, IntPtr.Zero)); + + foreach (var part in PartialFiles.All()) + { + + if (part.OwnerPid != 0 && winPids.Contains((uint)part.OwnerPid)) continue; + long pTotal = BrowserDownloads.TotalFor(part.Path); + + string? learned = Downloaders.AppFor(System.IO.Path.GetDirectoryName(part.Path)); + if (learned != null && OwnerLooksLike(learned, part.OwnerPid)) learned = null; + string label = part.Name.Length > 0 ? part.Name + : BrowserDownloads.NameFor(part.Path) ?? learned ?? "Downloading"; + + bool noName = part.Name.Length == 0 && label == "Downloading"; + + if (noName && ChromiumProgress.For(part.Path, part.Bytes) is { } live) + { + found.Add(new DlItem("file:" + part.Path, live.Name, + (int)Math.Clamp(live.Received * 100 / Math.Max(live.Total, 1), 0, 99), + live.Received, live.Total, false, false, part.Stalled, false, false, + false, false, part.OwnerPid != 0 ? ExeOfPid(part.OwnerPid) : null, + null, part.Path, part.OwnerPid, IntPtr.Zero)); + continue; + } + bool noPct = pTotal <= part.Bytes; + found.Add(new DlItem("file:" + part.Path, label, + noPct ? 0 : (int)Math.Clamp(part.Bytes * 100 / pTotal, 0, 99), + part.Bytes, noPct ? 0 : pTotal, noPct, noName, part.Stalled, false, false, + false, false, part.OwnerPid != 0 ? ExeOfPid(part.OwnerPid) : null, + null, part.Path, part.OwnerPid, IntPtr.Zero)); + } + } + catch { } + try { Publish(found); } catch { } + } + + public static void ShowInFolder() + { + var path = FilePath; + if (string.IsNullOrEmpty(path)) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { FileName = "explorer.exe", Arguments = $"/select,\"{path}\"", UseShellExecute = true }); + } + catch { } + } + + public static void CancelDownload() + { + bool browser = OwnerIsBrowser(); + CancelLog($"cancel clicked: name='{Name}' file='{FilePath}' browser={browser}"); + if (browser) { CancelInBrowser(); return; } + StopOwner(); + } + + private static void CancelInBrowser() + { + var h = OwnerWindow(); + CancelLog($"cancelInBrowser owner={OwnerPid} exe='{ExePath}' hwnd={h}"); + if (h == IntPtr.Zero) { Reveal(); return; } + string? target = null, partial = FilePath; + try + { + + if (Name is { Length: > 0 } shown && shown != "Downloading" && shown.Contains('.')) + target = shown; + if (target is null && partial is { Length: > 0 } fp + && PartialFiles.IsPartial(fp, out string clean) && clean.Length > 0) + target = clean; + } + catch { } + + System.Threading.Tasks.Task.Run(() => + { + try + { + + bool focused = FocusAndConfirm(h); + int rc = UiaCancel(h, target, focused); + + if (rc < 0 && focused) SendCtrlJ(); + bool stopped = StoppedGrowing(partial); + CancelLog($" uia rc={rc} target='{target}' focused={focused} stopped={stopped}"); + + if (!stopped) Reveal(); + } + catch (Exception ex) { CancelLog(" threw " + ex.Message); } + }); + } + + private static bool StoppedGrowing(string? partial) + { + if (string.IsNullOrEmpty(partial)) return false; + try + { + if (!System.IO.File.Exists(partial)) return true; + long a = new System.IO.FileInfo(partial!).Length; + Thread.Sleep(1500); + if (!System.IO.File.Exists(partial)) return true; + return new System.IO.FileInfo(partial!).Length == a; + } + catch { return true; } + } + + private static int UiaCancel(IntPtr hwnd, string? target, bool canTab) + { + try + { + string script; + using (var s = typeof(Downloads).Assembly.GetManifestResourceStream("Halo.Assets.uia-cancel.ps1")) + { + if (s == null) return -1; + using var r = new System.IO.StreamReader(s); + script = r.ReadToEnd(); + } + script = script.Replace("__HWND__", ((long)hwnd).ToString()) + .Replace("__CANTAB__", canTab ? "1" : "0") + .Replace("__TARGET__", (target ?? "").Replace("'", "''")); + + string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + $"halo-uia-{Guid.NewGuid():N}.ps1"); + + System.IO.File.WriteAllText(path, script, new UTF8Encoding(true)); + try + { + var psi = new ProcessStartInfo + { + FileName = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"), + Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{path}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + }; + using var p = Process.Start(psi); + if (p == null) return -1; + string outp = p.StandardOutput.ReadToEnd(); + + if (!p.WaitForExit(30000)) { try { p.Kill(true); } catch { } return -1; } + if (outp.Length > 0) CancelLog(" uia: " + outp.Replace("\r\n", " | ").Trim()); + return p.ExitCode; + } + finally { try { System.IO.File.Delete(path); } catch { } } + } + catch { return -1; } + } + + private static bool OwnerIsBrowser() + { + var exe = ExePath; + if (string.IsNullOrEmpty(exe)) return true; + try + { + string stem = System.IO.Path.GetFileNameWithoutExtension(exe!).ToLowerInvariant(); + if (Array.IndexOf(Browsers, stem) >= 0) return true; + return Process.GetProcessesByName(stem).Length >= 4; + } + catch { return true; } + } + + public static void RevealOwner() + { + var h = OwnerWindow(); + if (h == IntPtr.Zero) { Reveal(); return; } + Focus(h); + } + + public static void OpenDownloadsList() + { + var h = OwnerWindow(); + CancelLog($"openList owner={OwnerPid} exe='{ExePath}' hwnd={h}"); + if (h == IntPtr.Zero) { Reveal(); return; } + System.Threading.Tasks.Task.Run(() => + { + try { if (FocusAndConfirm(h)) SendCtrlJ(); } + catch (Exception ex) { CancelLog(" threw " + ex.Message); } + }); + } + + private static bool FocusAndConfirm(IntPtr h) + { + for (int attempt = 0; attempt < 2; attempt++) + { + Focus(h); + for (int i = 0; i < 60 && Win32.GetForegroundWindow() != h; i++) Thread.Sleep(25); + if (Win32.GetForegroundWindow() == h) { CancelLog($" focused=True attempt={attempt + 1}"); return true; } + } + CancelLog(" focused=False"); + return false; + } + + private static void SendCtrlJ() + { + const byte VkJ = 0x4A; const uint KeyUp = 2; + Win32.keybd_event((byte)Win32.VK_CONTROL, 0, 0, UIntPtr.Zero); + Win32.keybd_event(VkJ, 0, 0, UIntPtr.Zero); + Win32.keybd_event(VkJ, 0, KeyUp, UIntPtr.Zero); + Win32.keybd_event((byte)Win32.VK_CONTROL, 0, KeyUp, UIntPtr.Zero); + } + + internal static void CancelLog(string s) + { + try + { + System.IO.File.AppendAllText(System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "cancel-debug.txt"), + $"{DateTime.Now:HH:mm:ss.fff} {s}\r\n"); + } + catch { } + } + + private static readonly string[] NeverKill = + { "explorer", "svchost", "system", "dllhost", "searchhost", "runtimebroker", "halo.app", "halo" }; + + private static void StopOwner() + { + int pid = OwnerPid; var path = FilePath; + if (pid != 0 && pid != Environment.ProcessId) + { + string stem = ""; + try { stem = System.IO.Path.GetFileNameWithoutExtension(ExeOfPid(pid) ?? "").ToLowerInvariant(); } catch { } + if (Array.IndexOf(NeverKill, stem) < 0) + try { using var p = Process.GetProcessById(pid); p.Kill(entireProcessTree: true); p.WaitForExit(4000); } + catch { } + } + if (!string.IsNullOrEmpty(path) && PartialFiles.IsPartial(path!, out _)) + try { System.IO.File.Delete(path!); } catch { } + Name = null; FilePath = null; OwnerPid = 0; Percent = 0; Downloaded = Total = 0; NoPct = false; + Interlocked.Increment(ref Version); + } + + private static IntPtr OwnerWindow() + { + int pid = OwnerPid; + string? exe = ExePath; + IntPtr byPid = IntPtr.Zero, byExe = IntPtr.Zero; + try + { + Win32.EnumWindows((h, _) => + { + if (!Win32.IsWindowVisible(h) || Win32.GetWindowTextLengthW(h) < 1) return true; + Win32.GetWindowThreadProcessId(h, out uint wp); + if (pid != 0 && wp == (uint)pid) { byPid = h; return false; } + if (byExe == IntPtr.Zero && exe != null + && string.Equals(ExeOfPid((int)wp), exe, StringComparison.OrdinalIgnoreCase)) byExe = h; + return true; + }, IntPtr.Zero); + } + catch { } + return byPid != IntPtr.Zero ? byPid : byExe; + } + + private static bool Focus(IntPtr h) + { + try + { + Win32.ShowWindow(h, Win32.SW_RESTORE); + uint fore = Win32.GetWindowThreadProcessId(Win32.GetForegroundWindow(), out _); + uint self = Win32.GetCurrentThreadId(); + bool attached = fore != 0 && fore != self && Win32.AttachThreadInput(fore, self, true); + Win32.SetForegroundWindow(h); + if (attached) Win32.AttachThreadInput(fore, self, false); + return Win32.GetForegroundWindow() == h; + } + catch { return false; } + } + + private const string StoreAumid = "Microsoft.WindowsStore_8wekyb3d8bbwe!App"; + + private static bool OwnerLooksLike(string name, int pid) + { + if (pid == 0) return false; + try + { + string stem = System.IO.Path.GetFileNameWithoutExtension(ExeOfPid(pid) ?? ""); + return stem.Length > 0 && string.Equals(stem, name, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + private static string? ExeOfPid(int pid) + { + try { using var p = Process.GetProcessById(pid); return p.MainModule?.FileName; } + catch { return null; } + } + + private static string? SteamExe() + { + try + { + using var k = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Valve\Steam"); + string? dir = k?.GetValue("SteamPath") as string; + if (string.IsNullOrEmpty(dir)) return null; + string exe = System.IO.Path.Combine(System.IO.Path.GetFullPath(dir!.Replace('/', '\\')), "steam.exe"); + return System.IO.File.Exists(exe) ? exe : null; + } + catch { return null; } + } + + private static bool IsBrowser(IntPtr h) + { + try + { + Win32.GetWindowThreadProcessId(h, out uint pid); + if (pid == 0) return false; + using var p = Process.GetProcessById((int)pid); + string pn = p.ProcessName.ToLowerInvariant(); + foreach (var b in Browsers) if (pn.Contains(b)) return true; + return false; + } + catch { return false; } + } + + private static string? ExeOf(IntPtr h) + { + try + { + Win32.GetWindowThreadProcessId(h, out uint pid); + using var p = Process.GetProcessById((int)pid); + return p.MainModule?.FileName; + } + catch { return null; } + } + + private static string Clean(string title, Match m) + { + string s = title.Substring(m.Index + m.Length).TrimStart(']', ' ', '-', ':', '\t', '|', '»'); + return s.Length == 0 ? title.Trim() : s; + } +} diff --git a/src/Halo.App/Widgets/ExitBlock.cs b/src/Halo.App/Widgets/ExitBlock.cs new file mode 100644 index 0000000..3192ff2 --- /dev/null +++ b/src/Halo.App/Widgets/ExitBlock.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using Halo.ClaudeCode; + +namespace Halo.Widgets; + +internal static class ExitBlock +{ + private static readonly Color Green = Color.FromArgb(62, 207, 92); + private static readonly Color Amber = Color.FromArgb(255, 176, 32); + private static readonly Color Red = Color.FromArgb(229, 72, 77); + private static readonly Color Track = Color.FromArgb(38, 255, 255, 255); + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); + + private static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = Math.Min(radius * 2, Math.Min(r.Width, r.Height)); + var p = new GraphicsPath(); + if (d <= 0) { p.AddRectangle(r); return p; } + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private static float TextTop(Font f, float baseline) + => MathF.Round(baseline - f.FontFamily.GetCellAscent(f.Style) / (float)f.FontFamily.GetEmHeight(f.Style) * f.Size); + + private static void Text(Graphics g, string t, Font f, Brush b, float x, float baseline) + => g.DrawString(t, f, b, MathF.Round(x), TextTop(f, baseline), StringFormat.GenericTypographic); + + private static readonly StringFormat AdvanceFmt = + new(StringFormat.GenericTypographic) { FormatFlags = StringFormatFlags.MeasureTrailingSpaces }; + + private static float Advance(Graphics g, string t, Font f) + => t.Length == 0 ? 0f : g.MeasureString(t, f, System.Drawing.Point.Empty, AdvanceFmt).Width; + + internal static RectangleF Rect(float colL, float colR) => new(colL, 120, colR - colL, 76); + + private static Bitmap? _flagFit; + private static Bitmap? _flagFitFrom; + private static int _flagFitW; + + private static Bitmap FlagFitted(Bitmap src, int wantW) + { + if (_flagFit is { } cached && ReferenceEquals(_flagFitFrom, src) && _flagFitW == wantW) return cached; + int h = Math.Max(1, (int)Math.Round(wantW * (double)src.Height / src.Width)); + var bmp = new Bitmap(wantW, h, PixelFormat.Format32bppPArgb); + using (var gg = Graphics.FromImage(bmp)) + { + gg.InterpolationMode = InterpolationMode.HighQualityBicubic; + gg.PixelOffsetMode = PixelOffsetMode.HighQuality; + gg.DrawImage(src, new Rectangle(0, 0, wantW, h)); + } + var old = _flagFit; + _flagFit = bmp; + _flagFitFrom = src; + _flagFitW = wantW; + old?.Dispose(); + return bmp; + } + + private static Bitmap? _flagWave; + + private static Bitmap Waved(Bitmap src, float phase) + { + int w = src.Width, h = src.Height; + if (_flagWave is null || _flagWave.Width != w || _flagWave.Height != h) + { + _flagWave?.Dispose(); + _flagWave = new Bitmap(w, h, PixelFormat.Format32bppPArgb); + } + var dst = _flagWave; + + var sb = src.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppPArgb); + var db = dst.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format32bppPArgb); + try + { + unsafe + { + byte* sp = (byte*)sb.Scan0, dp = (byte*)db.Scan0; + + float amp = h * 0.15f, kx = MathF.Tau * 1.45f / w, ky = MathF.Tau * 0.55f / h; + for (int x = 0; x < w; x++) + { + float ramp = w < 2 ? 1f : x / (float)(w - 1); + ramp *= ramp * (3f - 2f * ramp); + for (int y = 0; y < h; y++) + { + float ang = kx * x - ky * y + phase; + + float sw = (MathF.Sin(ang) + 0.45f * MathF.Sin(2f * ang + 1.1f)) / 1.45f; + float cw = (MathF.Cos(ang) + 0.90f * MathF.Cos(2f * ang + 1.1f)) / 1.90f; + float shift = amp * ramp * sw; + + float shade = Math.Clamp(1f + 0.40f * ramp * cw, 0.58f, 1.38f); + float sy = Math.Clamp(y - shift, 0, h - 1.001f); + int y0 = (int)sy; + float f = sy - y0; + byte* p0 = sp + y0 * sb.Stride + x * 4; + byte* p1 = sp + Math.Min(y0 + 1, h - 1) * sb.Stride + x * 4; + byte* o = dp + y * db.Stride + x * 4; + for (int c = 0; c < 4; c++) + { + float v = (p0[c] * (1f - f) + p1[c] * f) * (c == 3 ? 1f : shade); + o[c] = (byte)Math.Clamp(v, 0f, 255f); + } + } + } + } + } + finally + { + src.UnlockBits(sb); + dst.UnlockBits(db); + } + return dst; + } + + internal static RectangleF DnsRowRect; + + internal static void Draw(Graphics g, float a, Font body, Font cap, + float ColR, float RightEdge, int[] api, int empty, int lost) + { + + const float y = 140f, fw = 28f, fh = 18f; + bool hov = WidgetInput.Over && Rect(ColR, RightEdge).Contains(WidgetInput.Mouse); + var flag = IpCountry.Flag; + if (flag != null) + { + var old = g.InterpolationMode; + var oldPx = g.PixelOffsetMode; + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = a }); + + var dst = new RectangleF(ColR, y - fh + 3, fw, fh); + + float sx = g.Transform.Elements[0]; + var fit = FlagFitted(flag, Math.Max(8, (int)MathF.Ceiling(fw * (sx > 0 ? sx : 1f)))); + + fit = Waved(fit, Environment.TickCount64 % 7000L / 7000f * MathF.Tau); + + using var tex = new TextureBrush(fit, new Rectangle(0, 0, fit.Width, fit.Height), ia) + { WrapMode = WrapMode.TileFlipXY }; + tex.Transform = new Matrix(dst.Width / fit.Width, 0, 0, dst.Height / fit.Height, dst.X, dst.Y); + using (var shape = Rounded(dst, 4f)) + { + g.FillPath(tex, shape); + using var bd = new Pen(Mul(Track, a), 1f); + g.DrawPath(bd, shape); + } + g.PixelOffsetMode = oldPx; + g.InterpolationMode = old; + } + + string who = IpCountry.Cc is { Length: > 0 } cc + ? (IpCountry.Isp is { Length: > 0 } isp ? $"{cc} · {isp}" : cc) + : "locating…"; + using (var wb = new SolidBrush(Mul(White, a * 0.9f))) + { + using var sf = new StringFormat(StringFormat.GenericTypographic) + { FormatFlags = StringFormatFlags.NoWrap, Trimming = StringTrimming.EllipsisCharacter }; + g.DrawString(who, body, wb, new RectangleF(ColR + fw + 13, TextTop(body, y), + RightEdge - ColR - fw - 13, body.Size * 1.6f), sf); + } + + string? scored = IpCountry.Split ? IpCountry.ApiIp : IpCountry.Ip; + if (hov) + { + IpRep.Want(scored); + DnsLeak.Want(scored, IpCountry.Split ? IpCountry.ApiCc : IpCountry.Cc); + } + + using var sf2 = new StringFormat(StringFormat.GenericTypographic) + { FormatFlags = StringFormatFlags.NoWrap, Trimming = StringTrimming.EllipsisCharacter }; + + var rows = new List<(string text, Color col, float alpha, string? lead)>(); + int dnsRow = -1; + + if (IpCountry.Split) + rows.Add(($"api exits {IpCountry.ApiCc ?? "?"} \u00b7 {IpCountry.ApiIp}", Amber, 0.9f, null)); + else if (!hov) + rows.Add((IpCountry.Ip ?? "", Dim, 0.85f, null)); + + if (hov) + { + rows.Add((IpCountry.Asn is { Length: > 0 } asn ? $"{asn} \u00b7 {RouteQuality(api, empty, lost)}" : RouteQuality(api, empty, lost), + Dim, 0.85f, null)); + + bool repFresh = string.Equals(IpRep.ForIp, scored, StringComparison.Ordinal) && IpRep.Verdict != null; + bool dnsFresh = string.Equals(DnsLeak.ForIp, scored, StringComparison.Ordinal) && DnsLeak.Done; + + if (!repFresh) rows.Add(("checking exit\u2026", Dim, 0.6f, null)); + else + { + + int mark = IpRep.Score(IpRep.Tor, IpRep.Abuser, IpRep.Bogon, IpRep.Vpn, IpRep.Proxy, + IpRep.Datacenter, IpRep.Abuse, IpCountry.Split, dnsFresh && DnsLeak.Leaking); + var markCol = MarkColour(mark); + + string full = $"{mark}/100 \u00b7 {IpRep.Verdict}" + + (IpRep.Abuse is { Length: > 0 } ab ? $" \u00b7 abuse {ab}" : ""); + if (Advance(g, full, cap) > RightEdge - ColR) full = $"{mark}/100 \u00b7 {IpRep.Verdict}"; + rows.Add((full, markCol, 0.95f, $"{mark}/100")); + } + + dnsRow = rows.Count; + if (!dnsFresh) + rows.Add((DnsLeak.Running ? "testing dns\u2026" : "dns \u2014", Dim, 0.6f, null)); + else + rows.Add((DnsLeak.Leaking + ? $"dns leak \u00b7 {DnsLeak.Resolvers} resolvers in {DnsLeak.Where}" + : $"dns ok \u00b7 {DnsLeak.Resolvers} resolvers in {DnsLeak.Where}", + DnsLeak.Leaking ? Red : Green, 0.95f, DnsLeak.Leaking ? "dns leak" : "dns ok")); + } + + DnsRowRect = dnsRow >= 0 && DnsLeak.ForIp != null + ? new RectangleF(ColR, y + 17 + dnsRow * 16 - 12, RightEdge - ColR, 16) + : RectangleF.Empty; + + for (int i = 0; i < rows.Count; i++) + { + var (text, col, alpha, lead) = rows[i]; + if (text.Length == 0) continue; + float by = TextTop(cap, y + 17 + i * 16); + + if (lead is { Length: > 0 } && text.StartsWith(lead, StringComparison.Ordinal)) + { + using (var lb = new SolidBrush(Mul(col, a * alpha))) + Text(g, lead, cap, lb, ColR, y + 17 + i * 16); + string rest = text.Substring(lead.Length); + if (rest.Length > 0) + using (var rb2 = new SolidBrush(Mul(Dim, a * 0.85f))) + g.DrawString(rest, cap, rb2, + new RectangleF(ColR + Advance(g, lead, cap), by, + RightEdge - ColR - Advance(g, lead, cap), cap.Size * 1.6f), sf2); + continue; + } + using var rb = new SolidBrush(Mul(col, a * alpha)); + g.DrawString(text, cap, rb, new RectangleF(ColR, by, RightEdge - ColR, cap.Size * 1.6f), sf2); + } + } + + private static Color MarkColour(int mark) + { + float t = Math.Clamp(mark / 100f, 0f, 1f); + var (from, to, k) = t < 0.5f ? (Red, Amber, t / 0.5f) : (Amber, Green, (t - 0.5f) / 0.5f); + return Color.FromArgb(255, + (int)(from.R + (to.R - from.R) * k), + (int)(from.G + (to.G - from.G) * k), + (int)(from.B + (to.B - from.B) * k)); + } + + private static string RouteQuality(int[] api, int empty, int lost) + { + int dropped = 0, seen = 0, last = empty; + foreach (var v in api) { if (v == empty) continue; seen++; if (v == lost) dropped++; } + for (int k = api.Length - 1; k >= 0; k--) if (api[k] != empty) { last = api[k]; break; } + string ms = last == empty ? "…" : last == lost ? "dropped" : $"{last} ms"; + return seen == 0 ? ms : $"{ms} · {dropped}/{seen} lost"; + } +} diff --git a/src/Halo.App/Widgets/FileTray.cs b/src/Halo.App/Widgets/FileTray.cs new file mode 100644 index 0000000..134fc29 --- /dev/null +++ b/src/Halo.App/Widgets/FileTray.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using System.Threading; + +namespace Halo.Widgets; + +internal sealed class FileTray : IWidget +{ + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Track = Color.FromArgb(46, 255, 255, 255); + private static readonly Color Accent = Color.FromArgb(120, 185, 255); + private static readonly Color Red = Color.FromArgb(255, 120, 110); + private static readonly FontFamily Fluent = new("Segoe Fluent Icons"); + + private static readonly object _lock = new(); + private static readonly List _paths = new(); + private static readonly HashSet _selected = new(StringComparer.OrdinalIgnoreCase); + private static int _version; + public static volatile bool DragActive; + + private static readonly Dictionary _anim = new(StringComparer.OrdinalIgnoreCase); + private static volatile bool _settled = true; + + public static int ReorderFrom = -1, ReorderTo = -1; + + private const int MaxItems = 30; + private static readonly string StorePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo", "tray.txt"); + + static FileTray() => Load(); + + public string Icon => ((char)0xE7B8).ToString(); + + public bool IsActive { get { lock (_lock) return DragActive || _paths.Count > 0; } } + + public static bool Holding { get { lock (_lock) return _paths.Count > 0; } } + public int Version => _version + (DragActive ? (int)(Environment.TickCount64 / 60) : 0); + public bool Animating => DragActive || !_settled; + + public static void SetDragActive(bool on) + { + if (DragActive == on) return; + DragActive = on; + Interlocked.Increment(ref _version); + } + + public static void Add(string path) + { + if (string.IsNullOrWhiteSpace(path)) return; + string full; + try { full = Path.GetFullPath(path); } catch { return; } + + if (full.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase)) + { + try + { + dynamic sh = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!; + string t = sh.CreateShortcut(full).TargetPath; + if (!string.IsNullOrWhiteSpace(t) && (File.Exists(t) || Directory.Exists(t))) full = Path.GetFullPath(t); + } + catch { } + } + if (!File.Exists(full) && !Directory.Exists(full)) return; + lock (_lock) + { + _paths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase)); + _paths.Insert(0, full); + if (_paths.Count > MaxItems) _paths.RemoveRange(MaxItems, _paths.Count - MaxItems); + Save(); + } + Interlocked.Increment(ref _version); + } + + private static void Remove(string path) + { + lock (_lock) { _paths.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); _selected.Remove(path); Save(); } + Interlocked.Increment(ref _version); + } + + public static void RemovePaths(string[] paths) + { + if (paths == null || paths.Length == 0) return; + lock (_lock) + { + foreach (var p in paths) { _paths.RemoveAll(x => string.Equals(x, p, StringComparison.OrdinalIgnoreCase)); _selected.Remove(p); } + Save(); + } + Interlocked.Increment(ref _version); + } + + public static int SelectedCount { get { lock (_lock) return _selected.Count; } } + private static bool IsSelected(string path) { lock (_lock) return _selected.Contains(path); } + + public static void ToggleSelect(string path) + { + lock (_lock) { if (!_selected.Remove(path)) _selected.Add(path); } + Interlocked.Increment(ref _version); + } + + public static void ClearSelection() + { + lock (_lock) { if (_selected.Count == 0) return; _selected.Clear(); } + Interlocked.Increment(ref _version); + } + + public static void RemoveSelected() + { + lock (_lock) { if (_selected.Count == 0) return; _paths.RemoveAll(_selected.Contains); _selected.Clear(); Save(); } + Interlocked.Increment(ref _version); + } + + public static string[] SelectionOrRow(string grabbed) + { + lock (_lock) + return _selected.Count > 0 && _selected.Contains(grabbed) + ? _paths.Where(_selected.Contains).ToArray() + : new[] { grabbed }; + } + + private static int IndexOf(string path) { lock (_lock) return _paths.FindIndex(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); } + + public static void BeginReorder(string grabbed) { ReorderFrom = ReorderTo = IndexOf(grabbed); Interlocked.Increment(ref _version); } + public static void UpdateReorder(int to) { if (to != ReorderTo) { ReorderTo = to; Interlocked.Increment(ref _version); } } + public static void CancelReorder() { ReorderFrom = ReorderTo = -1; Interlocked.Increment(ref _version); } + + public static void CommitReorder() + { + int from = ReorderFrom, to = ReorderTo; + ReorderFrom = ReorderTo = -1; + lock (_lock) + { + if (from >= 0 && to >= 0 && from != to && from < _paths.Count) + { + var moved = _paths[from]; + _paths.RemoveAt(from); + _paths.Insert(Math.Clamp(to, 0, _paths.Count), moved); + Save(); + } + } + Interlocked.Increment(ref _version); + } + + private static long _pruneAt; + + public static string[] Paths() => Snapshot(); + + private static string[] Snapshot() + { + lock (_lock) + { + if (Environment.TickCount64 - _pruneAt > 2000) + { + _pruneAt = Environment.TickCount64; + if (_paths.RemoveAll(p => !File.Exists(p) && !Directory.Exists(p)) > 0) + { + _selected.RemoveWhere(s => !_paths.Contains(s, StringComparer.OrdinalIgnoreCase)); + Save(); + Interlocked.Increment(ref _version); + } + } + return _paths.ToArray(); + } + } + + private static void Load() + { + try + { + if (!File.Exists(StorePath)) return; + lock (_lock) + foreach (var line in File.ReadAllLines(StorePath)) + { + var p = line.Trim(); + if (p.Length > 0 && (File.Exists(p) || Directory.Exists(p)) + && !_paths.Contains(p, StringComparer.OrdinalIgnoreCase)) + _paths.Add(p); + } + } + catch { } + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); + File.WriteAllLines(StorePath, _paths); + } + catch { } + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + var items = Snapshot(); + g.SmoothingMode = SmoothingMode.AntiAlias; + float sz = h - 14f, ix = 9, iy = (h - sz) / 2f; + + if (DragActive) + { + float pulse = 0.5f - 0.5f * MathF.Cos(Environment.TickCount % 2400 / 2400f * MathF.Tau); + using (var pb = new SolidBrush(Mul(Accent, fade * (0.06f + 0.14f * pulse)))) + using (var pp = Fx.PillPath(w, h, h / 2f)) + g.FillPath(pb, pp); + DrawTile(g, ix, iy, sz, fade, null); + DrawLabel(g, "Drop to add", ix + sz + 12, w, h, fade); + return; + } + + if (items.Length <= 1) + { + DrawTile(g, ix, iy, sz, fade, items.Length == 1 ? Halo.Notifications.ShellIcon.ForPath(items[0]) : null); + DrawLabel(g, items.Length == 1 ? Path.GetFileName(items[0]) : "Empty", ix + sz + 12, w, h, fade); + return; + } + + int n = Math.Min(4, items.Length); + float step = sz * 0.58f; + for (int i = n - 1; i >= 0; i--) + { + + using (var kb = new SolidBrush(Mul(Color.FromArgb(255, 12, 12, 14), fade))) + using (var kp = Fx.Rounded(new RectangleF(ix + i * step - 1.5f, iy - 1.5f, sz + 3, sz + 3), sz * 0.28f)) + g.FillPath(kb, kp); + DrawTile(g, ix + i * step, iy, sz, fade, Halo.Notifications.ShellIcon.ForPath(items[i])); + } + DrawLabel(g, $"{items.Length} files", ix + (n - 1) * step + sz + 12, w, h, fade); + } + + private static void DrawLabel(Graphics g, string s, float x, int w, int h, float fade) + { + using var f = new Font("Segoe UI Semibold", 14f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade)); + DrawEllipsized(g, s, f, b, x, (h - 18f) / 2f, w - x - 14, 18); + } + + private const float Pad = 22, HeaderH = 56, ColGap = 10, RowGap = 6, CellH = 44; + private const int Cols = 3; + + private static float CellW(int w) => (w - 2 * Pad - (Cols - 1) * ColGap) / Cols; + private static int RowsFor(int h) => Math.Max(1, (int)((h - HeaderH - 10) / (CellH + RowGap))); + private static int VisibleCells(int w, int h) => Cols * RowsFor(h); + private static RectangleF CellRect(int i, int w, int h) + { + int col = i % Cols, row = i / Cols; + return new RectangleF(Pad + col * (CellW(w) + ColGap), HeaderH + row * (CellH + RowGap), CellW(w), CellH); + } + private static RectangleF CellXRect(RectangleF cell) => new(cell.Right - 22, cell.Y + 3, 18, 18); + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) { _settled = true; _anim.Clear(); return; } + var items = Snapshot(); + g.SmoothingMode = SmoothingMode.AntiAlias; + float pulse = 0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 480f); + Fx.Glow(g, w, h, fade * (DragActive ? 0.6f + 0.4f * pulse : 0.9f), w * 0.5f, h * 0.4f, w * 0.9f, h * 1.2f, 34, Accent); + + using var title = new Font("Segoe UI Semibold", 21f, GraphicsUnit.Pixel); + using var body = new Font("Segoe UI", 14f, GraphicsUnit.Pixel); + using (var tb = new SolidBrush(Mul(White, fade))) + g.DrawString("File Tray", title, tb, Pad + 20, 10); + + int sel = SelectedCount; + if (sel > 0) DrawRemoveChip(g, w, fade, sel); + else if (items.Length > 0) + using (var cb = new SolidBrush(Mul(Dim, fade))) + using (var rf = new StringFormat(StringFormat.GenericTypographic) { Alignment = StringAlignment.Far }) + g.DrawString($"{items.Length} item{(items.Length == 1 ? "" : "s")}", body, cb, + new RectangleF(Pad, 20, w - Pad * 2, 24), rf); + + if (DragActive || items.Length == 0) { DrawDropZone(g, w, h, fade); return; } + + var order = DisplayOrder(items); + string? grabbed = ReorderFrom >= 0 && ReorderFrom < items.Length ? items[ReorderFrom] : null; + int vis = VisibleCells(w, h); + int shown = Math.Min(vis, order.Length); + + bool settled = true; + RectangleF grabbedRect = default; bool haveGrab = false; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < shown; i++) + { + string path = order[i]; + seen.Add(path); + var target = CellRect(i, w, h); + if (!_anim.TryGetValue(path, out var cur)) cur = target.Location; + float nx = cur.X + (target.X - cur.X) * 0.24f, ny = cur.Y + (target.Y - cur.Y) * 0.24f; + if (MathF.Abs(nx - target.X) < 0.4f && MathF.Abs(ny - target.Y) < 0.4f) { nx = target.X; ny = target.Y; } + else settled = false; + _anim[path] = new PointF(nx, ny); + var rect = new RectangleF(nx, ny, target.Width, target.Height); + if (path == grabbed) { grabbedRect = rect; haveGrab = true; continue; } + DrawCell(g, rect, path, fade, IsSelected(path), false); + } + if (haveGrab) DrawCell(g, grabbedRect, grabbed!, fade, IsSelected(grabbed!), true); + + if (_anim.Count > shown) + foreach (var k in _anim.Keys.Where(k => !seen.Contains(k)).ToList()) _anim.Remove(k); + _settled = settled; + if (!settled) Interlocked.Increment(ref _version); + + if (order.Length > vis) + using (var mb = new SolidBrush(Mul(Dim, fade))) + using (var cf = new StringFormat(StringFormat.GenericTypographic) { Alignment = StringAlignment.Far }) + g.DrawString($"+{order.Length - vis} more", body, mb, new RectangleF(Pad, h - Pad + 2, w - Pad * 2, 18), cf); + } + + private static string[] DisplayOrder(string[] items) + { + int from = ReorderFrom, to = ReorderTo; + if (from < 0 || to < 0 || from == to || from >= items.Length) return items; + var list = new List(items); + var moved = list[from]; + list.RemoveAt(from); + list.Insert(Math.Clamp(to, 0, list.Count), moved); + return list.ToArray(); + } + + public int RowIndexAt(int w, int h, PointF p) + { + int count = Math.Min(Snapshot().Length, VisibleCells(w, h)); + if (count == 0) return 0; + int col = Math.Clamp((int)((p.X - Pad) / (CellW(w) + ColGap)), 0, Cols - 1); + int row = Math.Clamp((int)((p.Y - HeaderH) / (CellH + RowGap)), 0, (count - 1) / Cols); + return Math.Clamp(row * Cols + col, 0, count - 1); + } + + private static RectangleF RemoveChipRect(int w) => new(w - Pad - 132, 16, 132, 28); + + private void DrawRemoveChip(Graphics g, int w, float fade, int n) + { + var r = RemoveChipRect(w); + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + using (var bg = new SolidBrush(Mul(Color.FromArgb(hov ? 60 : 34, Red), fade))) + using (var p = Fx.Rounded(r, r.Height / 2f)) + g.FillPath(bg, p); + DrawGlyph(g, new RectangleF(r.X + 12, r.Y, 26, r.Height), ((char)0xE74D).ToString(), 15f, fade * (hov ? 1f : 0.85f), Red); + using var f = new Font("Segoe UI Semibold", 14f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(hov ? White : Color.FromArgb(255, 200, 195), fade)); + using var sf = new StringFormat { LineAlignment = StringAlignment.Center }; + g.DrawString($"Remove {n}", f, b, new RectangleF(r.X + 34, r.Y, r.Width - 40, r.Height), sf); + } + + private void DrawDropZone(Graphics g, int w, int h, float fade) + { + + var box = new RectangleF(Pad, HeaderH - 2, w - Pad * 2, h - (HeaderH - 2) - Pad + 6); + bool active = DragActive; + float pulse = 0.5f + 0.5f * MathF.Sin(Environment.TickCount64 / 600f); + float border = fade * (active ? 0.75f + 0.25f * pulse : 0.5f); + + using (var fillp = Fx.Rounded(box, 18)) + { + using (var fb = new SolidBrush(Mul(Accent, fade * (active ? 0.10f + 0.06f * pulse : 0.045f)))) + g.FillPath(fb, fillp); + using (var pen = new Pen(Mul(active ? Accent : Dim, border), active ? 2.4f : 1.6f) + { DashStyle = DashStyle.Dash, DashPattern = new[] { 5f, 5f } }) + g.DrawPath(pen, fillp); + } + + float cx = box.X + box.Width / 2f, cy = box.Y + box.Height / 2f - 14, rad = 30; + using (var cb = new SolidBrush(Mul(Accent, fade * (active ? 0.24f + 0.1f * pulse : 0.14f)))) + g.FillEllipse(cb, cx - rad, cy - rad, rad * 2, rad * 2); + DrawGlyph(g, new RectangleF(cx - rad, cy - rad, rad * 2, rad * 2), ((char)0xE7B8).ToString(), + 30f, fade * (active ? 1f : 0.85f), active ? White : Accent); + + using var f1 = new Font("Segoe UI Semibold", 17f, GraphicsUnit.Pixel); + using var f2 = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var sf = new StringFormat { Alignment = StringAlignment.Center }; + using (var b1 = new SolidBrush(Mul(White, fade))) + g.DrawString(active ? "Release to add" : "Drop files here", f1, b1, new RectangleF(box.X, cy + rad + 6, box.Width, 24), sf); + if (!active) + using (var b2 = new SolidBrush(Mul(Dim, fade))) + g.DrawString("they'll stay in the tray", f2, b2, new RectangleF(box.X, cy + rad + 30, box.Width, 20), sf); + } + + private void DrawCell(Graphics g, RectangleF cell, string path, float fade, bool selected, bool lifted) + { + bool hov = WidgetInput.Over && cell.Contains(WidgetInput.Mouse); + Color bg = lifted ? Color.FromArgb(52, Accent) + : selected ? Color.FromArgb(34, Accent) + : hov ? Color.FromArgb(26, 255, 255, 255) + : Color.FromArgb(15, 255, 255, 255); + using (var b = new SolidBrush(Mul(bg, fade))) + using (var cp = Fx.Rounded(cell, 10)) + { + g.FillPath(b, cp); + if (selected || lifted) + using (var pen = new Pen(Mul(Color.FromArgb(lifted ? 150 : 90, Accent), fade), 1f)) + g.DrawPath(pen, cp); + } + + float ico = cell.Height - 16; + DrawTile(g, cell.X + 8, cell.Y + 8, ico, fade, Halo.Notifications.ShellIcon.ForPath(path)); + + float tx = cell.X + 8 + ico + 9, tw = cell.Right - tx - (hov ? 24 : 8); + using var nf = new Font("Segoe UI Semibold", 13.5f, GraphicsUnit.Pixel); + using var ff = new Font("Segoe UI", 10.5f, GraphicsUnit.Pixel); + using (var nb = new SolidBrush(Mul(White, fade))) + DrawEllipsized(g, Path.GetFileName(path), nf, nb, tx, cell.Y + 6, tw, 18); + using (var db = new SolidBrush(Mul(Dim, fade))) + DrawEllipsized(g, Dir(path), ff, db, tx, cell.Y + 24, tw, 14); + + if (hov) + { + var xr = CellXRect(cell); + bool hovX = xr.Contains(WidgetInput.Mouse); + using (var xb = new SolidBrush(Mul(Color.FromArgb(hovX ? 70 : 40, Red), fade))) + g.FillEllipse(xb, xr); + DrawGlyph(g, xr, ((char)0xE711).ToString(), xr.Width * 0.36f, fade * (hovX ? 1f : 0.85f), + hovX ? Color.FromArgb(255, 130, 120) : White); + } + } + + private static string Dir(string path) + { + try { return Path.GetFileName(Path.GetDirectoryName(path) ?? path) is { Length: > 0 } d ? d : path; } + catch { return path; } + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + { + var items = Snapshot(); + if (DragActive || items.Length == 0) return Array.Empty<(RectangleF, Action)>(); + var list = new List<(RectangleF, Action)>(); + if (SelectedCount > 0) list.Add((RemoveChipRect(w), _ => RemoveSelected())); + int count = Math.Min(items.Length, VisibleCells(w, h)); + for (int i = 0; i < count; i++) + { + string path = items[i]; + list.Add((CellXRect(CellRect(i, w, h)), _ => Remove(path))); + } + return list; + } + + public string? RowPathAt(int w, int h, PointF p) + { + var items = Snapshot(); + if (DragActive || items.Length == 0) return null; + int count = Math.Min(items.Length, VisibleCells(w, h)); + for (int i = 0; i < count; i++) + { + var cell = CellRect(i, w, h); + if (cell.Contains(p) && !CellXRect(cell).Contains(p)) return items[i]; + } + return null; + } + + public static void Open(string path) + { + try { Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); } catch { } + } + + private static void DrawTile(Graphics g, float x, float y, float sz, float fade, Bitmap? icon) + { + var box = new RectangleF(x, y, sz, sz); + using var path = Fx.Rounded(box, sz * 0.26f); + if (icon != null) + { + int s = Math.Max(1, (int)Math.Ceiling(sz)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = fade }); + int side = Math.Min(icon.Width, icon.Height); + sg.DrawImage(icon, new Rectangle(0, 0, s, s), (icon.Width - side) / 2, (icon.Height - side) / 2, + side, side, GraphicsUnit.Pixel, ia); + } + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(box.X, box.Y); + g.FillPath(tb, path); + } + else + { + using var gb = new SolidBrush(Mul(Track, fade)); + g.FillPath(gb, path); + DrawGlyph(g, box, ((char)0xE7B8).ToString(), sz * 0.5f, fade); + } + } + + private static void DrawGlyph(Graphics g, RectangleF r, string glyph, float px, float fade, Color? tint = null) + { + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(glyph, Fluent, (int)FontStyle.Regular, px, PointF.Empty, sf); + path.Flatten(); + var bnd = path.GetBounds(); + if (bnd.Width <= 0 || bnd.Height <= 0) return; + using var m = new Matrix(); + m.Translate(MathF.Round(r.X + (r.Width - bnd.Width) / 2f - bnd.X), + MathF.Round(r.Y + (r.Height - bnd.Height) / 2f - bnd.Y)); + path.Transform(m); + using var br = new SolidBrush(Mul(tint ?? White, fade * 0.9f)); + g.FillPath(br, path); + } + + private static void DrawEllipsized(Graphics g, string s, Font f, Brush b, float x, float y, float w, float h) + { + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap }; + g.DrawString(s, f, b, new RectangleF(x, y, w, h), sf); + } + + private static Color Mul(Color c, float a) => Color.FromArgb((int)(c.A * a), c.R, c.G, c.B); +} diff --git a/src/Halo.App/Widgets/Fx.cs b/src/Halo.App/Widgets/Fx.cs new file mode 100644 index 0000000..ed796ac --- /dev/null +++ b/src/Halo.App/Widgets/Fx.cs @@ -0,0 +1,651 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Runtime.CompilerServices; + +namespace Halo.Widgets; + +internal static class Fx +{ + public static readonly Color White = Color.FromArgb(238, 255, 255, 255); + + public const string NetLabel = "net"; + public const string ApiLabel = "api"; + public const string LossLabel = "loss"; + + public static string CleanText(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? ""; + try { return s.IsNormalized(System.Text.NormalizationForm.FormKC) ? s : s.Normalize(System.Text.NormalizationForm.FormKC); } + catch { return s; } + } + + public static bool IsRtl(string? s) + { + if (s == null) return false; + foreach (var c in s) if (c >= 0x0590 && c <= 0x08FF) return true; + return false; + } + + private static readonly ConditionalWeakTable AccentCache = new(); + + public static Color AccentOf(Bitmap? icon) + { + if (icon is null) return White; + if (AccentCache.TryGetValue(icon, out var cached)) return (Color)cached; + var accent = Accent(icon); + AccentCache.AddOrUpdate(icon, accent); + return accent; + } + + private static readonly Bitmap GlowTex = BuildGlowTex(); + + private static Bitmap BuildGlowTex() + { + const int n = 128; + + var bmp = new Bitmap(n, n, PixelFormat.Format32bppPArgb); + var data = bmp.LockBits(new Rectangle(0, 0, n, n), ImageLockMode.WriteOnly, PixelFormat.Format32bppPArgb); + var bytes = new byte[data.Stride * n]; + var rnd = new Random(1); + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + { + float dx = (x - n / 2f) / (n / 2f), dy = (y - n / 2f) / (n / 2f); + float t = MathF.Min(1f, MathF.Sqrt(dx * dx + dy * dy)); + + float f = MathF.Pow(1f - t, 1.8f); + float a = f * (255f + rnd.Next(-11, 12)); + int i = y * data.Stride + x * 4; + byte av = (byte)Math.Clamp((int)a, 0, 255); + bytes[i] = bytes[i + 1] = bytes[i + 2] = av; + bytes[i + 3] = av; + } + System.Runtime.InteropServices.Marshal.Copy(bytes, 0, data.Scan0, bytes.Length); + bmp.UnlockBits(data); + return bmp; + } + + public static void Glow(Graphics g, int w, int h, float fade, float cx, float cy, + float rx, float ry, float alpha, Color accent) + { + if (accent == White || fade <= 0.01f) return; + using var clip = PillClip(w, h); + var old = g.Clip; + + g.SetClip(clip, CombineMode.Intersect); + var oldInterp = g.InterpolationMode; + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + + using var ia = new ImageAttributes(); + ia.SetColorMatrix(new ColorMatrix + { + Matrix00 = accent.R / 255f, + Matrix11 = accent.G / 255f, + Matrix22 = accent.B / 255f, + Matrix33 = alpha * fade / 255f, + }); + + ia.SetWrapMode(WrapMode.Clamp); + g.DrawImage(GlowTex, new Rectangle((int)(cx - rx), (int)(cy - ry), (int)(rx * 2), (int)(ry * 2)), + 0, 0, GlowTex.Width, GlowTex.Height, GraphicsUnit.Pixel, ia); + g.InterpolationMode = oldInterp; + g.Clip = old; + } + + public static void PillBar(Graphics g, int w, int h, float fade, float frac, Color accent, float strength, + bool alive = false) + { + if (accent == White || fade <= 0.01f || strength <= 0f) return; + frac = Math.Clamp(frac, 0f, 1f); + + RgbToHsv(accent, out float ah, out float asat, out float av); + if (av < 0.62f) + accent = HsvToRgb(ah, asat < 0.12f ? asat : Math.Max(asat, 0.42f), 0.62f); + + using var pp = PillPath(w, h, h / 2f, 0.5f); + + RgbToHsv(accent, out float th, out float ts, out float tv); + var track = HsvToRgb(th, ts * 0.42f, Math.Max(0.16f, tv * 0.34f)); + + using (var tb = new SolidBrush(Alpha(track, fade * strength * (0.34f + 0.28f * strength)))) + g.FillPath(tb, pp); + if (frac <= 0.001f) return; + + float fill = w * frac; + + float breath = alive ? 0.5f - 0.5f * MathF.Cos(Environment.TickCount64 % 2400 / 2400f * MathF.Tau) : 0f; + + float lit = alive ? 0.78f + 0.42f * breath : 1f; + var solid = Alpha(accent, fade * 0.52f * strength * lit); + + if (fill > 6f) + { + var oldG = g.Clip; + g.SetClip(new RectangleF(0, 0, fill, h), CombineMode.Intersect); + Glow(g, w, h, fade, fill * 0.45f, h * 0.44f, Math.Max(fill, h * 1.2f), h * 1.9f, + 16 * strength * lit, accent); + g.Clip = oldG; + } + + if (frac >= 0.999f) { using (var fb = new SolidBrush(solid)) g.FillPath(fb, pp); } + else + { + + float soft = Math.Clamp(2.5f / w, 0.0008f, 0.02f); + float cut = Math.Clamp(fill / w, soft + 0.0005f, 0.9985f); + using var lb = new LinearGradientBrush(new RectangleF(0, 0, w, h), solid, Color.FromArgb(0, accent), + LinearGradientMode.Horizontal); + lb.InterpolationColors = new ColorBlend(4) + { + Positions = new[] { 0f, cut - soft, cut, 1f }, + Colors = new[] { solid, solid, Color.FromArgb(0, accent), Color.FromArgb(0, accent) }, + }; + g.FillPath(lb, pp); + } + + if (fill > 4f && strength >= 0.4f) + { + using var sheen = new LinearGradientBrush(new RectangleF(0, -0.5f, Math.Max(w, 1), h + 1f), + Color.White, Color.White, LinearGradientMode.Vertical); + sheen.InterpolationColors = new ColorBlend(4) + { + Positions = new[] { 0f, 0.34f, 0.70f, 1f }, + Colors = new[] + { + Alpha(Color.White, fade * 0.14f * strength), + Alpha(Color.White, fade * 0.05f * strength), + Color.FromArgb(0, 255, 255, 255), + Alpha(Color.FromArgb(0, 0, 0), fade * 0.10f * strength), + }, + }; + var oldC = g.Clip; + g.SetClip(new RectangleF(0, 0, fill, h), CombineMode.Intersect); + g.FillPath(sheen, pp); + g.Clip = oldC; + } + + if (fill > 8f && strength >= 0.5f) + { + float lipW = Math.Min(38f, fill), x0 = fill - lipW; + using var lip = new LinearGradientBrush(new RectangleF(x0 - 0.5f, 0, lipW + 1f, h), + Color.FromArgb(0, accent), Alpha(accent, fade * 0.3f * strength * lit), + LinearGradientMode.Horizontal); + var old = g.Clip; + g.SetClip(new RectangleF(x0, 0, lipW, h), CombineMode.Intersect); + g.FillPath(lip, pp); + g.Clip = old; + } + + if (fill > 6f) + { + var oldG = g.Clip; + g.SetClip(new RectangleF(0, 0, fill, h), CombineMode.Intersect); + Glow(g, w, h, fade, fill, h / 2f, h * 1.1f, h * 1.45f, + 13 * strength * lit, accent); + g.Clip = oldG; + } + } + + public static float CenterLift(Font f) + { + try + { + var ff = f.FontFamily; + var st = f.Style; + float em = ff.GetEmHeight(st); + if (em <= 0) return 0f; + float line = (ff.GetCellAscent(st) + ff.GetCellDescent(st)) / em; + float baseline = ff.GetCellAscent(st) / em; + const float capRatio = 0.70f; + float visual = baseline - capRatio / 2f; + return (visual - line / 2f) * f.Size; + } + catch { return 0f; } + } + + private static readonly Dictionary _inkOffsets = new(); + + public static PointF InkCentreOffsets(Font f, string s) + { + if (string.IsNullOrEmpty(s)) return PointF.Empty; + string key = f.FontFamily.Name + "|" + f.Style + "|" + f.Size.ToString("0.##") + "|" + s; + lock (_inkOffsets) + { + if (_inkOffsets.TryGetValue(key, out var v)) return v; + var off = PointF.Empty; + try + { + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(s, f.FontFamily, (int)f.Style, f.Size, PointF.Empty, sf); + var b = path.GetBounds(); + if (b.Width > 0 && b.Height > 0) + off = new PointF(-(b.Left + b.Width / 2f), -(b.Top + b.Height / 2f)); + } + catch { } + _inkOffsets[key] = off; + return off; + } + } + + public static float InkCentreOffset(Font f, string s) => InkCentreOffsets(f, s).Y; + + public static void PathProgress(Graphics g, GraphicsPath path, float frac, Pen pen) + { + if (frac <= 0f) return; + using var flat = (GraphicsPath)path.Clone(); + flat.Flatten(null, 0.2f); + var pts = flat.PathPoints; + if (pts.Length < 2) return; + + float total = 0f; + var seg = new float[pts.Length]; + for (int i = 1; i < pts.Length; i++) + { + float dx = pts[i].X - pts[i - 1].X, dy = pts[i].Y - pts[i - 1].Y; + seg[i] = MathF.Sqrt(dx * dx + dy * dy); + total += seg[i]; + } + if (total <= 0f) return; + + float want = Math.Clamp(frac, 0f, 1f) * total, run = 0f; + for (int i = 1; i < pts.Length; i++) + { + if (run + seg[i] <= want) { g.DrawLine(pen, pts[i - 1], pts[i]); run += seg[i]; continue; } + + float k = seg[i] > 0f ? (want - run) / seg[i] : 0f; + if (k > 0.001f) + g.DrawLine(pen, pts[i - 1], new PointF( + pts[i - 1].X + (pts[i].X - pts[i - 1].X) * k, + pts[i - 1].Y + (pts[i].Y - pts[i - 1].Y) * k)); + return; + } + } + + public static void GlyphCentred(Graphics g, RectangleF r, string glyph, Font f, Brush brush) + { + var off = InkCentreOffsets(f, glyph); + using var sf = new StringFormat(StringFormat.GenericTypographic) { FormatFlags = StringFormatFlags.NoWrap }; + g.DrawString(glyph, f, brush, new PointF(r.X + r.Width / 2f + off.X, r.Y + r.Height / 2f + off.Y), sf); + } + + public static float CapCentreOffset(Font f) => InkCentreOffset(f, "H"); + + public static Color Alpha(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); + + private static GraphicsPath PillClip(int w, int h) => PillPath(w, h, Math.Min(h / 2f, 30f)); + + public static GraphicsPath PillPath(int w, int h, float r) => PillPath(w, h, r, 0f); + + public static GraphicsPath PillPath(int w, int h, float r, float inset) + { + float x0 = inset, y0 = inset, x1 = w - inset, y1 = h - inset; + float d = Math.Min(r, Math.Min(x1 - x0, y1 - y0) / 2f) * 2f; + var p = new GraphicsPath(); + p.AddLine(x0, y0, x1, y0); + p.AddArc(x1 - d, y1 - d, d, d, 0, 90); + p.AddArc(x0, y1 - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + public static Color Accent(Bitmap art) + { + try + { + using var small = new Bitmap(12, 12, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(small)) + { + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.DrawImage(art, 0, 0, 12, 12); + } + float best = -1f; Color pick = White; + for (int y = 0; y < 12; y++) + for (int x = 0; x < 12; x++) + { + var p = small.GetPixel(x, y); + if (p.A < 128) continue; + RgbToHsv(p, out _, out float s, out float v); + if (v < 0.2f || v > 0.98f) continue; + float score = s * (v < 0.85f ? v : 1.7f - v); + if (score > best) { best = score; pick = p; } + } + if (best <= 0.05f) return White; + RgbToHsv(pick, out float ph, out float ps, out float pv); + return HsvToRgb(ph, Math.Min(1f, ps * 1.1f), Math.Max(pv, 0.85f)); + } + catch { return White; } + } + + public static Bitmap Badge(Bitmap icon, char ch) + { + var b = new Bitmap(icon.Width, icon.Height, PixelFormat.Format32bppPArgb); + using var g = Graphics.FromImage(b); + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + g.DrawImage(icon, 0, 0, icon.Width, icon.Height); + float d = icon.Width * 0.42f, x = icon.Width - d, y = icon.Height - d; + using (var bg = new SolidBrush(Color.FromArgb(230, 24, 24, 26))) + g.FillEllipse(bg, x, y, d, d); + using var f = new Font("Segoe UI Semibold", d * 0.62f, GraphicsUnit.Pixel); + using var wb = new SolidBrush(Color.FromArgb(240, 255, 255, 255)); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(ch.ToString(), f, wb, new RectangleF(x, y - d * 0.02f, d, d), sf); + return b; + } + + public static Color Shade(Color c, int step) + { + if (step <= 0) return c; + RgbToHsv(c, out float h, out float s, out float v); + return HsvToRgb(h, Math.Min(1f, s * (1f + 0.22f * step)), Math.Max(0.35f, v * (1f - 0.26f * step))); + } + + public static void RgbToHsv(Color c, out float h, out float s, out float v) + { + float r = c.R / 255f, g = c.G / 255f, b = c.B / 255f; + float max = Math.Max(r, Math.Max(g, b)), min = Math.Min(r, Math.Min(g, b)), d = max - min; + v = max; s = max <= 0f ? 0f : d / max; h = 0f; + if (d > 0f) + { + if (max == r) h = (g - b) / d % 6f; + else if (max == g) h = (b - r) / d + 2f; + else h = (r - g) / d + 4f; + h *= 60f; if (h < 0f) h += 360f; + } + } + + public static Color HsvToRgb(float h, float s, float v) + { + float c = v * s, x = c * (1f - Math.Abs(h / 60f % 2f - 1f)), m = v - c; + float r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return Color.FromArgb(255, (int)((r + m) * 255), (int)((g + m) * 255), (int)((b + m) * 255)); + } + + private static Bitmap? _flagGhost; + private static Bitmap? _flagGhostFor; + + public static Bitmap FlagGhost(Bitmap flag) + { + if (_flagGhost != null && ReferenceEquals(_flagGhostFor, flag)) return _flagGhost; + const int fw = 420, fh = 264, amp = 12; + const int oh = fh + amp * 2; + using var scaled = new Bitmap(fw, fh, PixelFormat.Format32bppArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.DrawImage(flag, new Rectangle(0, 0, fw, fh), 0, 0, flag.Width, flag.Height, GraphicsUnit.Pixel); + } + var src = scaled.LockBits(new Rectangle(0, 0, fw, fh), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + var sb = new byte[src.Stride * fh]; + System.Runtime.InteropServices.Marshal.Copy(src.Scan0, sb, 0, sb.Length); + int stride = src.Stride; + scaled.UnlockBits(src); + + var bmp = new Bitmap(fw, oh, PixelFormat.Format32bppPArgb); + var dst = bmp.LockBits(new Rectangle(0, 0, fw, oh), ImageLockMode.WriteOnly, PixelFormat.Format32bppPArgb); + var ob = new byte[dst.Stride * oh]; + for (int x = 0; x < fw; x++) + { + float ph = x / (float)fw * MathF.Tau * 2.4f; + float dy = amp * MathF.Sin(ph); + float shade = 1f + 0.10f * MathF.Cos(ph); + float ex = (x - fw / 2f) / (fw / 2f); + float fadeX = 1f - ex * ex; + for (int y = 0; y < oh; y++) + { + float sy = y - amp - dy; + int y0 = (int)MathF.Floor(sy); + if (y0 < -1 || y0 >= fh) continue; + float fr = sy - y0; + int ia = Math.Clamp(y0, 0, fh - 1) * stride + x * 4; + int ib = Math.Clamp(y0 + 1, 0, fh - 1) * stride + x * 4; + + float aa = (y0 >= 0 ? sb[ia + 3] : 0) * (1f - fr) + (y0 + 1 < fh ? sb[ib + 3] : 0) * fr; + float ey = (sy - fh / 2f) / (fh / 2f); + float fadeY = Math.Max(0f, 1f - ey * ey); + float alpha = aa / 255f * fadeX * fadeY; + if (alpha <= 0.004f) continue; + int o = y * dst.Stride + x * 4; + for (int c = 0; c < 3; c++) + { + float ch = (sb[ia + c] * (1f - fr) + sb[ib + c] * fr) * shade; + ob[o + c] = (byte)(Math.Min(ch, 255f) * alpha); + } + ob[o + 3] = (byte)(alpha * 255f); + } + } + System.Runtime.InteropServices.Marshal.Copy(ob, 0, dst.Scan0, ob.Length); + bmp.UnlockBits(dst); + var old = _flagGhost; + _flagGhost = bmp; + _flagGhostFor = flag; + old?.Dispose(); + return bmp; + } + + public static void DrawFlagGhost(Graphics g, System.Drawing.Bitmap? flag, int w, int h, float a) + { + if (flag is null) return; + var ghost = FlagGhost(flag); + const int gw = 210; + int gh = ghost.Height * gw / ghost.Width; + DrawFlagGhost(g, flag, new RectangleF((w - gw) / 2f, (h - gh) / 2f + 4, gw, gh), a); + } + + public static void DrawFlagGhost(Graphics g, System.Drawing.Bitmap? flag, RectangleF dest, float a) + { + if (flag is null) return; + var ghost = FlagGhost(flag); + float strength = dest.Width >= 160 ? 0.16f : dest.Width >= 90 ? 0.22f : 0.30f; + using var ia = new ImageAttributes(); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = strength * a }); + var oldInterp = g.InterpolationMode; + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + g.DrawImage(ghost, Rectangle.Round(dest), 0, 0, ghost.Width, ghost.Height, GraphicsUnit.Pixel, ia); + g.InterpolationMode = oldInterp; + } + + public static void DrawSeekArrow(Graphics g, RectangleF chip, bool forward, float alpha, string label = "10") + { + var c = Color.FromArgb((int)(238 * alpha), 255, 255, 255); + float cx = chip.X + chip.Width / 2f, cy = chip.Y + chip.Height / 2f, r = chip.Width * 0.30f; + g.SmoothingMode = SmoothingMode.AntiAlias; + const float gap = 80f; + using (var pen = new Pen(c, 1.8f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawArc(pen, cx - r, cy - r, r * 2, r * 2, 270f + gap / 2f, 360f - gap); + + float deg = forward ? 270f - gap / 2f : 270f + gap / 2f; + float th = deg * MathF.PI / 180f; + var p = new PointF(cx + r * MathF.Cos(th), cy + r * MathF.Sin(th)); + var dir = forward ? new PointF(-MathF.Sin(th), MathF.Cos(th)) : new PointF(MathF.Sin(th), -MathF.Cos(th)); + var perp = new PointF(-dir.Y, dir.X); + float ah = chip.Width * 0.13f, aw = chip.Width * 0.10f; + using (var b = new SolidBrush(c)) + using (var tri = new GraphicsPath()) + { + tri.AddPolygon(new[] + { + new PointF(p.X + dir.X * ah, p.Y + dir.Y * ah), + new PointF(p.X - dir.X * ah * 0.4f + perp.X * aw, p.Y - dir.Y * ah * 0.4f + perp.Y * aw), + new PointF(p.X - dir.X * ah * 0.4f - perp.X * aw, p.Y - dir.Y * ah * 0.4f - perp.Y * aw), + }); + g.FillPath(b, tri); + } + using var f = new Font("Segoe UI Semibold", chip.Width * 0.26f, GraphicsUnit.Pixel); + using var tb = new SolidBrush(c); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(label, f, tb, new RectangleF(chip.X, chip.Y + 0.5f, chip.Width, chip.Height), sf); + } + + public static void DrawCcMark(Graphics g, RectangleF chip, float alpha) + { + using var f = new Font("Segoe UI Semibold", chip.Width * 0.34f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Color.FromArgb((int)(238 * alpha), 255, 255, 255)); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString("CC", f, b, chip, sf); + } + + public static void DrawPipMark(Graphics g, RectangleF chip, float alpha) + { + var c = Color.FromArgb((int)(238 * alpha), 255, 255, 255); + g.SmoothingMode = SmoothingMode.AntiAlias; + float w = chip.Width * 0.44f, h = w * 0.72f; + var f = new RectangleF(chip.X + (chip.Width - w) / 2f, chip.Y + (chip.Height - h) / 2f, w, h); + using (var op = Rounded(f, 2f)) + using (var pen = new Pen(c, 1.5f)) + g.DrawPath(pen, op); + + var a = new PointF(f.X + w * 0.30f, f.Y + h * 0.30f); + var b = new PointF(f.Right - w * 0.22f, f.Bottom - h * 0.26f); + var d = new PointF(b.X - a.X, b.Y - a.Y); + float len = MathF.Sqrt(d.X * d.X + d.Y * d.Y); + d = new PointF(d.X / len, d.Y / len); + using (var pen = new Pen(c, 1.6f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) + g.DrawLine(pen, a, b); + float ah = w * 0.28f; + var perp = new PointF(-d.Y, d.X); + using var tb = new SolidBrush(c); + using var tri = new GraphicsPath(); + tri.AddPolygon(new[] + { + b, + new PointF(b.X - d.X * ah + perp.X * ah * 0.55f, b.Y - d.Y * ah + perp.Y * ah * 0.55f), + new PointF(b.X - d.X * ah - perp.X * ah * 0.55f, b.Y - d.Y * ah - perp.Y * ah * 0.55f), + }); + g.FillPath(tb, tri); + } + + public static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = Math.Min(radius * 2, Math.Min(r.Width, r.Height)); + var p = new GraphicsPath(); + if (d <= 0) { p.AddRectangle(r); return p; } + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + internal static Color UsageColor(float f) => + f <= 0.5f ? UsageGreen + : f <= 0.75f ? HueLerp(UsageGreen, UsageAmber, (f - 0.5f) / 0.25f) + : HueLerp(UsageAmber, UsageRed, Math.Clamp((f - 0.75f) / 0.25f, 0f, 1f)); + + private static readonly Color UsageGreen = Color.FromArgb(62, 207, 92); + private static readonly Color UsageAmber = Color.FromArgb(255, 176, 32); + private static readonly Color UsageRed = Color.FromArgb(229, 72, 77); + + private static readonly Color RingHot = Color.FromArgb(255, 122, 36); + + internal static int FitChars(Graphics g, float avail, float px) + { + if (avail <= 4f || px <= 1f) return 0; + try + { + using var f = new Font("Segoe UI Semibold", px, GraphicsUnit.Pixel); + + const string sample = "the quick brown fox jumps over it"; + float em = g.MeasureString(sample, f, int.MaxValue, StringFormat.GenericTypographic).Width + / sample.Length; + return em > 0.5f ? (int)MathF.Floor(avail / em) : 0; + } + catch { return 0; } + } + + internal static Color SlotColor(string? slot) => slot switch + { + "running" => Color.FromArgb(62, 207, 92), + "reading" or "peeking" => Color.FromArgb(53, 208, 232), + "fetching" or "searching" => Color.FromArgb(20, 190, 175), + "writing" or "patching" or "publishing" + => Color.FromArgb(169, 139, 255), + + "digging" or "reviewing" => Color.FromArgb(170, 220, 50), + "planning" or "plotting" or "skill" + => Color.FromArgb(240, 196, 60), + + "delegating" or "consulting" + => Color.FromArgb(190, 80, 175), + + "watching" => Color.FromArgb(150, 160, 200), + "asking" => Color.FromArgb(255, 95, 138), + "unknown" => Color.FromArgb(255, 150, 26), + "compacting" => Color.FromArgb(91, 157, 255), + _ => Color.FromArgb(62, 207, 92), + }; + + internal static Color MoodRing(Color state, in Halo.Agents.MoodContext ctx, bool hueIsFree = false) + { + + float squeeze = MathF.Max(Ramp(ctx.ContextFrac, 0.55f, 0.95f), Ramp(ctx.UsageFrac, 0.70f, 0.98f)); + float drag = ctx.Running is { } r ? Ramp((float)r.TotalMinutes, 2f, 12f) : 0f; + float lift = MathF.Max(squeeze, 0.55f * drag); + var c = state; + + if (hueIsFree) + { + var target = HueLerp(UsageAmber, RingHot, squeeze); + + c = HueLerp(c, target, MathF.Max(0.45f * squeeze, 0.18f * drag * (1f - squeeze))); + } + + RgbToHsv(c, out float h, out float s, out float v); + c = HsvToRgb(h, + Math.Clamp(s + (hueIsFree ? 0f : 0.10f) * lift, 0f, 1f), + Math.Clamp(v + 0.10f * lift, 0f, 1f)); + + if (ctx.Hour is >= 0 and <= 5) c = Scale(c, 0.93f); + + return Color.FromArgb(state.A, c.R, c.G, c.B); + } + + private static float Ramp(float v, float from, float to) + => to <= from ? 0f : Math.Clamp((v - from) / (to - from), 0f, 1f); + + private static Color Scale(Color c, float k) => Color.FromArgb( + c.A, (int)Math.Clamp(c.R * k, 0, 255), (int)Math.Clamp(c.G * k, 0, 255), (int)Math.Clamp(c.B * k, 0, 255)); + + private static Color HueLerp(Color a, Color b, float t) + { + var (h1, s1, v1) = ToHsv(a); + var (h2, s2, v2) = ToHsv(b); + float dh = h2 - h1; + if (dh > 180) dh -= 360; else if (dh < -180) dh += 360; + return FromHsv(h1 + dh * t, s1 + (s2 - s1) * t, v1 + (v2 - v1) * t); + } + + private static (float h, float s, float v) ToHsv(Color c) + { + float r = c.R / 255f, g2 = c.G / 255f, b = c.B / 255f; + float max = Math.Max(r, Math.Max(g2, b)), min = Math.Min(r, Math.Min(g2, b)), d = max - min; + float h = d == 0 ? 0 + : max == r ? 60 * (((g2 - b) / d) % 6) + : max == g2 ? 60 * ((b - r) / d + 2) + : 60 * ((r - g2) / d + 4); + if (h < 0) h += 360; + return (h, max == 0 ? 0 : d / max, max); + } + + private static Color FromHsv(float h, float s, float v) + { + h = (h % 360 + 360) % 360; + float c = v * s, x = c * (1 - MathF.Abs((h / 60) % 2 - 1)), m = v - c; + var (r, g2, b) = h < 60 ? (c, x, 0f) : h < 120 ? (x, c, 0f) : h < 180 ? (0f, c, x) + : h < 240 ? (0f, x, c) : h < 300 ? (x, 0f, c) : (c, 0f, x); + return Color.FromArgb(255, (int)((r + m) * 255), (int)((g2 + m) * 255), (int)((b + m) * 255)); + } + +} diff --git a/src/Halo.App/Widgets/GameInstall.cs b/src/Halo.App/Widgets/GameInstall.cs new file mode 100644 index 0000000..ad09767 --- /dev/null +++ b/src/Halo.App/Widgets/GameInstall.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Halo.Widgets; + +internal static class GameInstall +{ + private static readonly object _lock = new(); + private static string? _folder, _name, _storeId, _logo; + private static long _bytes, _total, _lastGrewTick = -600_000, _scanAt; + private static bool _baselined, _startupDone, _sizeAsked; + private static int _busy; + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) }; + + public static string? LogoPath { get { lock (_lock) return _logo; } } + + public static bool Poll(out string name, out long done, out long total, out bool stalled) + { + name = "Xbox game"; done = 0; total = 0; stalled = false; + + string? folder = FindStagingFolder(); + if (folder == null) { lock (_lock) { _folder = null; _name = null; _bytes = _total = 0; } _startupDone = true; return false; } + if (folder != Volatile.Read(ref _folder)) + { + lock (_lock) { _folder = folder; _bytes = _total = 0; _baselined = false; _sizeAsked = false; (_name, _storeId, _logo) = ReadConfig(folder); } + if (_startupDone) Interlocked.Exchange(ref _lastGrewTick, Environment.TickCount64); + } + _startupDone = true; + FetchTotalOnce(); + + long now = Environment.TickCount64; + long sinceGrew = now - Interlocked.Read(ref _lastGrewTick); + bool active = sinceGrew < 30_000; + long interval = active ? 20000 : 40000; + + if (now - Interlocked.Read(ref _scanAt) > interval && Interlocked.Exchange(ref _busy, 1) == 0) + { + Interlocked.Exchange(ref _scanAt, now); + ThreadPool.QueueUserWorkItem(_ => { try { Rescan(); } finally { Volatile.Write(ref _busy, 0); } }); + } + + if (sinceGrew > 90_000) return false; + lock (_lock) { name = _name ?? "Xbox game"; done = _bytes; total = _total; } + stalled = !active; + return true; + } + + private static string? FindStagingFolder() + { + try + { + foreach (var d in DriveInfo.GetDrives()) + { + if (d.DriveType != DriveType.Fixed) continue; + string root = Path.Combine(d.Name, "XboxGames"); + if (!Directory.Exists(root)) continue; + foreach (var dir in Directory.GetDirectories(root)) + if (Guid.TryParse(Path.GetFileName(dir), out _)) return dir; + } + } + catch { } + return null; + } + + private static void Rescan() + { + string? folder; + lock (_lock) folder = _folder; + if (folder == null) return; + long size = DirSize(folder); + lock (_lock) + { + if (folder != _folder) return; + + if (_baselined && size > _bytes + 262_144) Interlocked.Exchange(ref _lastGrewTick, Environment.TickCount64); + _bytes = size; _baselined = true; + } + } + + private static long DirSize(string dir) + { + long sum = 0; + try + { + foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + try { sum += new FileInfo(f).Length; } catch { } + } + catch { } + return sum; + } + + private static void FetchTotalOnce() + { + string? id; + lock (_lock) { if (_sizeAsked || _storeId == null) return; _sizeAsked = true; id = _storeId; } + ThreadPool.QueueUserWorkItem(_ => + { + long t = QueryCatalogSize(id!); + if (t > 0) lock (_lock) { if (_storeId == id) _total = t; } + }); + } + + private static long QueryCatalogSize(string storeId) + { + try + { + string url = $"https://displaycatalog.mp.microsoft.com/v7.0/products/{storeId}?market=US&languages=en-US&fieldsTemplate=Details"; + var root = JsonNode.Parse(Http.GetStringAsync(url).GetAwaiter().GetResult()); + long max = 0; + if (root?["Product"]?["DisplaySkuAvailabilities"] is JsonArray skus) + foreach (var s in skus) + if (s?["Sku"]?["Properties"]?["Packages"] is JsonArray pkgs) + foreach (var p in pkgs) + if (p?["MaxDownloadSizeInBytes"]?.GetValue() is { } b && b > max) max = b; + return max; + } + catch { return 0; } + } + + private static (string? name, string? storeId, string? logo) ReadConfig(string dir) + { + string? name = null, storeId = null, logo = null; + try + { + string cfg = Path.Combine(dir, "Content", "MicrosoftGame.config"); + if (File.Exists(cfg)) + { + string s = File.ReadAllText(cfg); + var sm = Regex.Match(s, @"([^<]+)"); + if (sm.Success) storeId = sm.Groups[1].Value.Trim(); + var nm = Regex.Match(s, @"DefaultDisplayName=""([^""]+)"""); + if (nm.Success) name = nm.Groups[1].Value.Trim(); + + var lm = Regex.Match(s, @"Square150x150Logo=""([^""]+)"""); + if (!lm.Success) lm = Regex.Match(s, @"StoreLogo=""([^""]+)"""); + if (lm.Success) + { + string p = Path.Combine(dir, "Content", lm.Groups[1].Value.Replace('/', '\\')); + if (File.Exists(p)) logo = p; + } + } + } + catch { } + if (name == null) + try + { + string mf = Path.Combine(dir, "Content", "appxmanifest.xml"); + if (File.Exists(mf)) + { + var m = Regex.Match(File.ReadAllText(mf), @"([^<]+)"); + if (m.Success) name = m.Groups[1].Value.Trim(); + } + } + catch { } + return (name, storeId, logo); + } +} diff --git a/src/Halo.App/Widgets/GenericAgentWidget.cs b/src/Halo.App/Widgets/GenericAgentWidget.cs new file mode 100644 index 0000000..fa97bac --- /dev/null +++ b/src/Halo.App/Widgets/GenericAgentWidget.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using Halo.ClaudeCode; + +namespace Halo.Widgets; + +internal sealed class GenericAgentWidget : IWidget +{ + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Green = Color.FromArgb(62, 207, 92); + private static readonly Color Amber = Color.FromArgb(255, 176, 32); + private static readonly Color Red = Color.FromArgb(229, 72, 77); + + public static string Directory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".halo", "agents"); + + public static StatusStore NewStore() => new( + Path.Combine(Directory, "agent.json"), StatusStore.GetProcessStartedAt, watchFiles: true); + + private readonly StatusStore _store; + private readonly int _slot; + + public GenericAgentWidget(StatusStore store, int slot) + { + _store = store; + _slot = slot; + } + + private CcStatus? Live => _store.SessionLive(_slot); + + public string Icon => ""; + public bool IsActive => Live is not null; + public IEnumerable OwnerPids => Live is { } st ? new[] { st.Pid, st.ConsolePid } : Array.Empty(); + public int Version => _store.Version; + public string GroupKey => Live?.Name?.ToLowerInvariant() ?? "agent"; + public Color? Ring => Live is { } st ? RingColor(st) : null; + + private static Color RingColor(CcStatus st) => st.State switch + { + "working" => Green, + "waiting_input" or "waiting" => Amber, + "error" => Red, + _ => White, + }; + + private Bitmap? _icon, _badged; + private string? _iconKey; + + public Bitmap? IconImage + { + get + { + var path = Live?.Icon; + if (string.IsNullOrEmpty(path)) return null; + if (path != _iconKey) + { + _badged?.Dispose(); + _icon?.Dispose(); + try { _icon = new Bitmap(path); } catch { _icon = null; } + _badged = _icon is null ? null : Fx.Badge(_icon, (char)('1' + _slot)); + _iconKey = path; + } + return _badged; + } + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + => Array.Empty<(RectangleF, Action)>(); + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + var st = Live; + if (st is null) return; + g.SmoothingMode = SmoothingMode.AntiAlias; + float sz = (h - 16f) * 0.82f, x = 13, y = (h - sz) / 2f; + using (var pen = new Pen(Mul(RingColor(st), fade * 0.9f), 1.9f)) + g.DrawEllipse(pen, x - 2.5f, y - 2.5f, sz + 5f, sz + 5f); + DrawIconCircle(g, x, y, sz, fade); + + string text = st.State == "working" ? Verb(st) + Elapsed(st) : st.Name ?? "agent"; + using var f = new Font("Segoe UI Semibold", 15f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, LineAlignment = StringAlignment.Center, FormatFlags = StringFormatFlags.NoWrap }; + + g.DrawString(text, f, b, new RectangleF(x + sz + 8, -1.5f, w - (x + sz + 8) - 14, h), sf); + } + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) return; + var st = Live; + if (st is null) return; + g.SmoothingMode = SmoothingMode.AntiAlias; + Fx.Glow(g, w, h, fade, w * 0.16f, h * 0.35f, w * 0.85f, h * 1.2f, 30, Fx.AccentOf(IconImage)); + + float x = 26; + using (var pen = new Pen(Mul(RingColor(st), fade * 0.9f), 2.2f)) + g.DrawEllipse(pen, x - 3, 23, 46, 46); + DrawIconCircle(g, x, 26, 40, fade); + + using var titleF = new Font("Segoe UI Semibold", 22f, GraphicsUnit.Pixel); + using var bodyF = new Font("Segoe UI", 15f, GraphicsUnit.Pixel); + using var dimB = new SolidBrush(Mul(Dim, fade)); + using var whiteB = new SolidBrush(Mul(White, fade)); + g.DrawString(st.Name ?? "Agent", titleF, whiteB, x + 56, 28); + g.DrawString(Verb(st) + Elapsed(st), bodyF, dimB, x + 56, 60); + if (!string.IsNullOrEmpty(st.Cwd)) + g.DrawString(st.Cwd, bodyF, dimB, x, 108); + if (!string.IsNullOrEmpty(st.Message)) + g.DrawString(st.Message, bodyF, whiteB, x, 136); + } + + private void DrawIconCircle(Graphics g, float x, float y, float sz, float fade) + { + var img = IconImage; + if (img != null) + { + using var path = new GraphicsPath(); + path.AddEllipse(x, y, sz, sz); + var clip = g.Clip; + g.SetClip(path); + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.DrawImage(img, x, y, sz, sz); + g.Clip = clip; + return; + } + using var f = new Font("Segoe MDL2 Assets", sz * 0.62f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade)); + using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(Icon, f, b, new RectangleF(x, y, sz, sz), sf); + } + + private static string Verb(CcStatus st) => st.State switch + { + "working" => string.IsNullOrEmpty(st.CurrentTool) ? "working…" : st.CurrentTool!, + "waiting_input" or "waiting" => "your move ;)", + "error" => "error", + _ => "idle", + }; + + private static string Elapsed(CcStatus st) + { + if (st.State != "working" || !DateTimeOffset.TryParse(st.StartedAt, null, + System.Globalization.DateTimeStyles.RoundtripKind, out var t0)) return ""; + var e = DateTimeOffset.UtcNow - t0; + if (e < TimeSpan.Zero || e > TimeSpan.FromDays(1)) return ""; + return e.TotalMinutes >= 1 ? $" · {(int)e.TotalMinutes}m {e.Seconds}s" : $" · {e.Seconds}s"; + } + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); +} diff --git a/src/Halo.App/Widgets/Greeting.cs b/src/Halo.App/Widgets/Greeting.cs new file mode 100644 index 0000000..4f973cf --- /dev/null +++ b/src/Halo.App/Widgets/Greeting.cs @@ -0,0 +1,136 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace Halo.Widgets; + +internal static class Greeting +{ + + private const float InkW = 284f, InkH = 92f; + + private static readonly float[] Stroke = + [ + -145.66f, 43.747f, -145.66f, 43.747f, -86.107f, 10.264f, -81.851f, -26.162f, + -79.424f, -46.943f, -98.573f, -44.137f, -101.426f, -23.013f, -103.757f, -5.755f, + -109.596f, 40.561f, -109.596f, 40.561f, -109.596f, 40.561f, -103.979f, -0.034f, + -85.851f, 1.753f, -65.936f, 4.083f, -91.979f, 40.05f, -69f, 40.305f, + -48.573f, 40.532f, -27.639f, 22.688f, -26.873f, 10.943f, -25.99f, -2.599f, + -44.362f, -4.886f, -50.022f, 11.966f, -55.226f, 27.461f, -43.584f, 44.902f, + -23.54f, 40.581f, 7.341f, 33.922f, 22.483f, -10.827f, 23.936f, -26.077f, + 25.467f, -42.162f, 13.723f, -43.694f, 6.574f, -29.397f, -0.104f, -16.04f, + -11.245f, 37.085f, 12.958f, 41.583f, 41.809f, 46.944f, 64.277f, -5.906f, + 67.086f, -23.779f, 69.802f, -41.066f, 58.656f, -45.952f, 50.234f, -30.673f, + 41.166f, -14.223f, 27.843f, 44.077f, 59.937f, 41.326f, 86.746f, 39.028f, + 76.916f, 2.264f, 102.898f, -0.05f, 114.562f, -1.088f, 119.386f, 9.92f, + 118.532f, 21.029f, 117.638f, 32.646f, 106.66f, 42.475f, 95.809f, 40.943f, + 85.898f, 39.544f, 80.838f, 25.973f, 83.425f, 17.072f, 86.617f, 6.094f, + 96.662f, 0.12f, 102.898f, -0.05f, 111.766f, -0.29f, 116.234f, 5.327f, + 124.149f, 5.199f, 131.179f, 5.086f, 138.27f, -2.922f, 138.27f, -2.922f, + ]; + + internal static readonly string[] Lines = ["i'm halo", "welcome"]; + + private static readonly string[] Hands = ["Ink Free", "Segoe Script", "Segoe Print", "Gabriola"]; + + internal static Font LineFont(float px) + { + foreach (var name in Hands) + { + try + { + var f = new Font(name, px, FontStyle.Regular, GraphicsUnit.Pixel); + if (string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)) return f; + f.Dispose(); + } + catch { } + } + return new Font("Segoe UI", px, FontStyle.Italic, GraphicsUnit.Pixel); + } + + internal static RectangleF InkBox(float w, float h) + { + float mx = w * 0.11f, my = h * 0.20f; + return new RectangleF(mx, my, w - mx * 2f, h - my * 2f); + } + + private static GraphicsPath? _path; + private static float _len; + + private static GraphicsPath Path() + { + if (_path is not null) return _path; + var p = new GraphicsPath(); + var cur = new PointF(Stroke[0], Stroke[1]); + for (int i = 2; i + 5 < Stroke.Length; i += 6) + { + var c1 = new PointF(Stroke[i], Stroke[i + 1]); + var c2 = new PointF(Stroke[i + 2], Stroke[i + 3]); + var to = new PointF(Stroke[i + 4], Stroke[i + 5]); + p.AddBezier(cur, c1, c2, to); + cur = to; + } + using (var probe = (GraphicsPath)p.Clone()) + { + probe.Flatten(null, 0.15f); + var pts = probe.PathPoints; + float len = 0f; + for (int i = 1; i < pts.Length; i++) + len += MathF.Sqrt(MathF.Pow(pts[i].X - pts[i - 1].X, 2) + MathF.Pow(pts[i].Y - pts[i - 1].Y, 2)); + _len = len; + } + _path = p; + return p; + } + + private static RectangleF _bounds; + + private static RectangleF Bounds() + { + if (_bounds.Width > 0f) return _bounds; + using var probe = (GraphicsPath)Path().Clone(); + probe.Flatten(null, 0.15f); + _bounds = probe.GetBounds(); + return _bounds; + } + + internal static void DrawHello(Graphics g, RectangleF box, float written, float alpha, Color ink, + float weight = 9f) + { + if (alpha <= 0.004f || written <= 0f) return; + var path = Path(); + + var save = g.Save(); + try + { + + var mark = Bounds(); + float grow = weight; + float scale = MathF.Min(box.Width / (mark.Width + grow), box.Height / (mark.Height + grow)); + g.TranslateTransform(box.X + box.Width / 2f, box.Y + box.Height / 2f); + g.ScaleTransform(scale, scale); + g.TranslateTransform(-(mark.X + mark.Width / 2f), -(mark.Y + mark.Height / 2f)); + + using var pen = new Pen(Color.FromArgb((int)(Math.Clamp(alpha, 0f, 1f) * ink.A), ink), weight) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + LineJoin = LineJoin.Round, + DashCap = DashCap.Round, + }; + if (written < 1f) + { + + float on = MathF.Max(0.001f, _len * written / weight); + float off = _len * 2f / weight; + pen.DashPattern = [on, off]; + } + g.DrawPath(pen, path); + } + finally { g.Restore(save); } + } + + internal static void DrawLine(Graphics g, string text, RectangleF box, float written, float alpha, + Color ink, float weight) + => Script.Draw(g, text, box, written, alpha, ink, weight); +} diff --git a/src/Halo.App/Widgets/IWidget.cs b/src/Halo.App/Widgets/IWidget.cs new file mode 100644 index 0000000..632ef7d --- /dev/null +++ b/src/Halo.App/Widgets/IWidget.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Drawing; + +namespace Halo.Widgets; + +internal readonly record struct AgentNotice(string? State, DateTimeOffset? CompactedAt, string? Message) +{ + internal static AgentNotice None => new(null, null, null); +} + +internal interface IWidget +{ + string Icon { get; } + + Bitmap? IconImage => null; + + float IconOffsetX => 0f; + + bool IsActive { get; } + int Version { get; } + + bool Animating => false; + + Color? Ring => null; + + float RingProgress => -1f; + + AgentNotice AgentNotice => AgentNotice.None; + + IEnumerable OwnerPids => Array.Empty(); + + void DrawContent(Graphics g, int w, int h, float expandFade); + + void DrawCollapsed(Graphics g, int w, int h, float fade) { } + + IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h); + + IReadOnlyList<(RectangleF rect, Action onClick)> CollapsedButtons(int w, int h) + => Array.Empty<(RectangleF, Action)>(); +} diff --git a/src/Halo.App/Widgets/KeyInject.cs b/src/Halo.App/Widgets/KeyInject.cs new file mode 100644 index 0000000..720eed3 --- /dev/null +++ b/src/Halo.App/Widgets/KeyInject.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Halo.Interop; + +namespace Halo.Widgets; + +internal static class KeyInject +{ + private const byte VK_MENU = 0x12; + + public static void Send(IntPtr hwnd, byte vk, bool alt = false) + => SendSeq(hwnd, new[] { vk }, alt); + + public static void SendSeq(IntPtr hwnd, byte[] vks, bool alt = false) + { + if (hwnd == IntPtr.Zero || vks.Length == 0) return; + Task.Run(() => + { + try + { + if (Win32.GetForegroundWindow() != hwnd) + { + IntPtr fg = Win32.GetForegroundWindow(); + uint me = Win32.GetCurrentThreadId(); + uint fgT = Win32.GetWindowThreadProcessId(fg, out _); + uint tgT = Win32.GetWindowThreadProcessId(hwnd, out _); + Win32.AttachThreadInput(me, fgT, true); + Win32.AttachThreadInput(me, tgT, true); + for (int i = 0; i < 4 && Win32.GetForegroundWindow() != hwnd; i++) + { + Win32.SetForegroundWindow(hwnd); + Thread.Sleep(40); + } + Win32.AttachThreadInput(me, fgT, false); + Win32.AttachThreadInput(me, tgT, false); + Thread.Sleep(70); + if (Win32.GetForegroundWindow() != hwnd) return; + } + if (alt) Win32.keybd_event(VK_MENU, 0, 0, UIntPtr.Zero); + foreach (byte vk in vks) + { + Win32.keybd_event(vk, 0, 0, UIntPtr.Zero); + Win32.keybd_event(vk, 0, Win32.KEYEVENTF_KEYUP, UIntPtr.Zero); + Thread.Sleep(35); + } + if (alt) Win32.keybd_event(VK_MENU, 0, Win32.KEYEVENTF_KEYUP, UIntPtr.Zero); + } + catch { } + }); + } +} diff --git a/src/Halo.App/Widgets/MediaFileInfo.cs b/src/Halo.App/Widgets/MediaFileInfo.cs new file mode 100644 index 0000000..d62c29c --- /dev/null +++ b/src/Halo.App/Widgets/MediaFileInfo.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Halo.Widgets; + +internal static class MediaFileInfo +{ + private static readonly object _lock = new(); + private static readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private static readonly HashSet _inFlight = new(StringComparer.OrdinalIgnoreCase); + + private static readonly string[] VideoExt = + { ".mkv", ".mp4", ".avi", ".mov", ".webm", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".ogv" }; + + public static long? Size(string? title, Action? onFound = null) + { + if (string.IsNullOrWhiteSpace(title) || !LooksLikeFile(title)) return null; + lock (_lock) + { + if (_cache.TryGetValue(title, out var known)) return known; + if (!_inFlight.Add(title)) return null; + } + _ = Task.Run(() => + { + long? found = null; + try { found = Lookup(title!); } catch { } + lock (_lock) { _cache[title!] = found; _inFlight.Remove(title!); } + if (found is not null) onFound?.Invoke(); + }); + return null; + } + + public static string Human(long bytes) + { + if (bytes <= 0) return ""; + double gb = bytes / 1024d / 1024d / 1024d; + if (gb >= 1d) return gb.ToString(gb >= 10d ? "0" : "0.#", + System.Globalization.CultureInfo.InvariantCulture) + " GB"; + double mb = bytes / 1024d / 1024d; + if (mb >= 1d) return mb.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " MB"; + return (bytes / 1024d).ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " KB"; + } + + internal static bool LooksLikeFile(string title) + { + var t = title.Trim(); + if (t.Length is < 6 or > 200 || t.IndexOfAny(new[] { '/', '\\' }) >= 0) return false; + if (HasVideoExt(t)) return true; + + return t.Split('.', StringSplitOptions.RemoveEmptyEntries).Length >= 4; + } + + private static bool HasVideoExt(string name) + { + var t = name.ToLowerInvariant(); + foreach (var e in VideoExt) if (t.EndsWith(e, StringComparison.Ordinal)) return true; + return false; + } + + internal static bool SameFile(string candidatePath, string title) + { + var name = Path.GetFileName(candidatePath); + if (string.Equals(name, title, StringComparison.OrdinalIgnoreCase)) return true; + if (!HasVideoExt(name)) return false; + var stem = name.Substring(0, name.LastIndexOf('.')); + return string.Equals(stem, title, StringComparison.OrdinalIgnoreCase); + } + + private static long? Lookup(string title) + { + var recent = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", "Windows", "Recent"); + if (!Directory.Exists(recent)) return null; + + var exact = Path.Combine(recent, title + ".lnk"); + if (File.Exists(exact) && Verify(exact, title) is { } hit) return hit; + + var prefix = HasVideoExt(title) ? title.Substring(0, title.LastIndexOf('.')) : title; + foreach (var lnk in Directory.EnumerateFiles(recent, "*.lnk")) + { + if (!Path.GetFileName(lnk).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + if (Verify(lnk, title) is { } size) return size; + } + return null; + } + + private static long? Verify(string lnk, string title) + { + byte[] bytes; + try { bytes = File.ReadAllBytes(lnk); } catch { return null; } + if (bytes.Length is 0 or > 1_000_000) return null; + + foreach (var cand in Paths(bytes)) + { + if (!SameFile(cand, title)) continue; + try + { + var fi = new FileInfo(cand); + if (fi.Exists && fi.Length > 0) return fi.Length; + } + catch { } + } + return null; + } + + private static IEnumerable Paths(byte[] bytes) + { + foreach (var s in new[] { Encoding.Latin1.GetString(bytes), Encoding.Unicode.GetString(bytes) }) + { + for (int i = 0; i + 3 < s.Length; i++) + { + if (s[i + 1] != ':' || s[i + 2] != '\\') continue; + char d = s[i]; + if (!char.IsLetter(d)) continue; + int end = i + 3; + while (end < s.Length && !char.IsControl(s[end]) && s[end] != '\0') end++; + if (end - i > 6) yield return s.Substring(i, end - i); + i = end; + } + } + } +} diff --git a/src/Halo.App/Widgets/MediaSessions.cs b/src/Halo.App/Widgets/MediaSessions.cs new file mode 100644 index 0000000..2462e29 --- /dev/null +++ b/src/Halo.App/Widgets/MediaSessions.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Windows.Media.Control; + +namespace Halo.Widgets; + +internal sealed class MediaSessions +{ + public const int MaxSlots = 3; + + private readonly object _lock = new(); + private readonly string[] _slotIds = new string[MaxSlots]; + private GlobalSystemMediaTransportControlsSessionManager? _mgr; + + public event Action? Changed; + + public MediaSessions() + { + for (int i = 0; i < MaxSlots; i++) _slotIds[i] = ""; + _ = InitAsync(); + } + + private async Task InitAsync() + { + try + { + _mgr = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); + _mgr.SessionsChanged += (_, _) => Reassign(); + _mgr.CurrentSessionChanged += (_, _) => Reassign(); + Reassign(); + } + catch { } + } + + private void Reassign() + { + var mgr = _mgr; + if (mgr == null) return; + List live; + try + { + live = mgr.GetSessions().Select(s => s.SourceAppUserModelId ?? "") + .Where(id => id.Length > 0).Distinct().ToList(); + } + catch { return; } + lock (_lock) + { + for (int i = 0; i < MaxSlots; i++) + if (_slotIds[i].Length > 0 && !live.Contains(_slotIds[i])) _slotIds[i] = ""; + foreach (var id in live) + { + if (Array.IndexOf(_slotIds, id) >= 0) continue; + int free = Array.IndexOf(_slotIds, ""); + if (free >= 0) _slotIds[free] = id; + } + } + Changed?.Invoke(); + } + + public GlobalSystemMediaTransportControlsSession? Session(int slot) + { + var mgr = _mgr; + if (mgr == null || slot < 0 || slot >= MaxSlots) return null; + string id; + lock (_lock) { id = _slotIds[slot]; } + if (id.Length == 0) return null; + try { foreach (var s in mgr.GetSessions()) if ((s.SourceAppUserModelId ?? "") == id) return s; } + catch { } + return null; + } + + public string SlotApp(int slot) + { + string id; + lock (_lock) { id = slot >= 0 && slot < MaxSlots ? _slotIds[slot] : ""; } + return id.Length == 0 ? "" : System.IO.Path.GetFileNameWithoutExtension(id).ToLowerInvariant(); + } +} diff --git a/src/Halo.App/Widgets/MediaWidget.cs b/src/Halo.App/Widgets/MediaWidget.cs new file mode 100644 index 0000000..a750e78 --- /dev/null +++ b/src/Halo.App/Widgets/MediaWidget.cs @@ -0,0 +1,1337 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Threading.Tasks; +using Windows.Media.Control; +using Windows.Storage.Streams; + +namespace Halo.Widgets; + +internal sealed class MediaWidget : IWidget +{ + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Track = Color.FromArgb(46, 255, 255, 255); + + private readonly object _lock = new(); + private readonly MediaSessions _sessions; + private readonly int _slot; + private GlobalSystemMediaTransportControlsSession? _session; + + private string? _title, _artist, _trackKey, _appId; + private bool _playing, _isVideo; + private double _rate = 1.0; + private bool _rateEnabled; + private bool _seekEnabled; + private bool _thumbWide; + + private GlobalSystemMediaTransportControlsSessionPlaybackStatus _status; + private byte[]? _thumb; + private TimeSpan _pos, _end; + private TimeSpan _start, _minSeek, _maxSeek; + private TimeSpan? _seekPending; + private DateTimeOffset _seekSentAt, _seekAskedAt; + private TimeSpan _reported; + private TimeSpan _prevEnd; + private long _trackAt; + private int _seekTries; + private bool _seekBusy; + private DateTime _posAt; + private int _version; + + private string? _artKey; + private Bitmap? _art; + private volatile bool _artStale; + private Bitmap[]? _frames; + private int[]? _delays; + private int _totalDelay; + private Color _accent = White; + + public MediaWidget(MediaSessions sessions, int slot) + { + _sessions = sessions; + _slot = slot; + _sessions.Changed += Resync; + Resync(); + } + + public string App => _sessions.SlotApp(_slot); + + public int Slot => _slot; + public string? TitleText { get { lock (_lock) return _title; } } + public string? ArtistText { get { lock (_lock) return _artist; } } + public bool Playing { get { lock (_lock) return _playing; } } + + public string Icon => "\uE768"; + + public bool IsActive + { + get + { + lock (_lock) + { + return _title != null + && (_status == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing + || _status == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused); + } + } + } + public int Version { get { lock (_lock) { return _version; } } } + + public Bitmap? IconImage + { + get + { + string? id; lock (_lock) { id = _appId; } + var app = AppIcon.ForSessionApp(id); + if (app != null) return app; + EnsureArt(); + return _art; + } + } + + private void EnsureArt() + { + byte[]? thumb; string? key; + lock (_lock) { thumb = _thumb; key = _trackKey; } + + if (key != _artKey || _artStale) + { + _artStale = false; + DisposeFrames(); + (_frames, _delays) = DecodeFrames(thumb); + _art = _frames is { Length: > 0 } ? _frames[0] : null; + _totalDelay = 0; + if (_delays != null) foreach (var d in _delays) _totalDelay += d; + _animatedArt = _frames is { Length: > 1 } && _totalDelay > 0; + _artKey = key; + _accent = _art != null ? Fx.Accent(_art) : White; + _palette = Palette(_accent); + } + } + + private void DisposeFrames() + { + if (_frames != null) foreach (var f in _frames) f?.Dispose(); + _frames = null; _delays = null; _art = null; + } + + private Bitmap? CurArt() + { + if (_frames == null || _frames.Length == 0) return null; + if (_frames.Length == 1 || _totalDelay <= 0) return _frames[0]; + int t = (int)(Environment.TickCount64 % _totalDelay); + for (int i = 0; i < _frames.Length; i++) { t -= _delays![i]; if (t < 0) return _frames[i]; } + return _frames[^1]; + } + + private void Resync() => Hook(_sessions.Session(_slot)); + + private void Hook(GlobalSystemMediaTransportControlsSession? s) + { + string? newId = s?.SourceAppUserModelId; + lock (_lock) + { + if (s != null && _session != null && newId == _appId) return; + _session = s; _appId = newId; + } + if (s == null) { Clear(); return; } + try + { + s.MediaPropertiesChanged += (_, __) => RefreshProps(s); + s.PlaybackInfoChanged += (_, __) => RefreshPlayback(s); + s.TimelinePropertiesChanged += (_, __) => RefreshTimeline(s); + RefreshProps(s); + RefreshPlayback(s); + RefreshTimeline(s); + } + catch { } + } + + private async void RefreshProps(GlobalSystemMediaTransportControlsSession s) + { + try + { + var props = await s.TryGetMediaPropertiesAsync(); + string title = Fx.CleanText(props.Title); + string artist = Fx.CleanText(props.Artist); + string key = title + "" + artist; + byte[]? thumb = props.Thumbnail != null ? await ReadStream(props.Thumbnail) : null; + bool wide = ThumbIsWide(thumb); + bool trackChanged, chase; + int epoch; + lock (_lock) + { + if (!ReferenceEquals(_session, s)) return; + trackChanged = key != _trackKey; + bool firstTrack = _trackKey == null; + _title = title.Length > 0 ? title : (artist.Length > 0 ? artist : null); + _artist = artist; + _trackKey = key; + if (thumb != null || trackChanged) { _thumb = thumb; _thumbWide = wide; } + + if (trackChanged && !firstTrack) + { + _prevEnd = _end; + _trackAt = Environment.TickCount64; + _pos = _end = _start = TimeSpan.Zero; + _minSeek = _maxSeek = TimeSpan.Zero; + _posAt = DateTime.UtcNow; + } + if (trackChanged) _trackEpoch++; + _version++; + + chase = _thumb is not { Length: > 0 }; + epoch = _trackEpoch; + } + if (trackChanged) DebugLog(title); + if (chase) ChaseArt(s, epoch); + } + catch { } + } + + private static readonly int[] ArtRetries = [350, 700, 1400, 2600, 4500, 6000]; + + private async void ChaseArt(GlobalSystemMediaTransportControlsSession s, int epoch) + { + if (_chasing) return; + _chasing = true; + try + { + foreach (int wait in ArtRetries) + { + await System.Threading.Tasks.Task.Delay(wait); + lock (_lock) + { + if (!ReferenceEquals(_session, s) || _trackEpoch != epoch) return; + if (_thumb is { Length: > 0 }) return; + } + try + { + var props = await s.TryGetMediaPropertiesAsync(); + byte[]? thumb = props.Thumbnail != null ? await ReadStream(props.Thumbnail) : null; + if (thumb is not { Length: > 0 }) continue; + bool wide = ThumbIsWide(thumb); + lock (_lock) + { + if (!ReferenceEquals(_session, s) || _trackEpoch != epoch) return; + _thumb = thumb; + _thumbWide = wide; + _version++; + } + _artStale = true; + return; + } + catch { } + } + } + catch { } + finally { _chasing = false; } + } + + private volatile bool _chasing; + + private void DebugLog(string title) + { + try + { + string app = App, id; bool video; lock (_lock) { id = _appId ?? ""; video = _isVideo; } + System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Halo", "media-debug.txt"), + $"{DateTime.Now:HH:mm:ss} app='{app}' aumid='{id}' video={video} title='{title}'\r\n"); + } + catch { } + } + + private void RefreshPlayback(GlobalSystemMediaTransportControlsSession s) + { + try + { + var info = s.GetPlaybackInfo(); + lock (_lock) + { + if (!ReferenceEquals(_session, s)) return; + bool moved = _status != info.PlaybackStatus + || _rateEnabled != info.Controls.IsPlaybackRateEnabled + || _seekEnabled != info.Controls.IsPlaybackPositionEnabled; + _status = info.PlaybackStatus; + _playing = info.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + _isVideo = info.PlaybackType == Windows.Media.MediaPlaybackType.Video; + _rateEnabled = info.Controls.IsPlaybackRateEnabled; + _seekEnabled = info.Controls.IsPlaybackPositionEnabled; + if (info.PlaybackRate is double pr && pr > 0 && Math.Abs(pr - _rate) > 0.001) + { _rate = pr; moved = true; } + if (moved) _version++; + } + } + catch { } + } + + private void RefreshTimeline(GlobalSystemMediaTransportControlsSession s) + { + try + { + var t = s.GetTimelineProperties(); + lock (_lock) + { + if (!ReferenceEquals(_session, s)) return; + + if (_prevEnd > TimeSpan.Zero) + { + if (MediaTiming.IsLeftover(t.EndTime, _prevEnd, Environment.TickCount64 - _trackAt)) return; + _prevEnd = TimeSpan.Zero; + } + + if (MediaTiming.IsBlank(t.StartTime, t.EndTime, _start, _end)) return; + _start = t.StartTime; + _minSeek = t.MinSeekTime; + _maxSeek = t.MaxSeekTime; + _end = t.EndTime; + + bool repeated = t.Position == _reported; + _reported = t.Position; + + bool stale = _seekPending is { } want + && (t.Position - want).Duration() > TimeSpan.FromSeconds(1.5); + bool confirming = _seekPending is not null; + if (!stale) + { + _seekPending = null; + + if (MediaTiming.ShouldRestamp(repeated, _playing, confirming)) + { + bool moved = (t.Position - _pos).Duration() > TimeSpan.FromMilliseconds(250); + _pos = t.Position; + _posAt = DateTime.UtcNow; + if (moved) _version++; + } + } + } + } + catch { } + } + + private long _pollAt; + private void PollTimeline() + { + long now = Environment.TickCount64; + if (now - _pollAt < 200) return; + _pollAt = now; + if (Cur() is { } s) { RefreshTimeline(s); RefreshPlayback(s); } + NudgeSeek(); + } + + private void Clear() + { + lock (_lock) + { + _status = GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed; + if (_title == null) return; + _title = _artist = _trackKey = null; + _thumb = null; + _pos = _end = _start = _minSeek = _maxSeek = TimeSpan.Zero; + _seekPending = null; + _version++; + } + } + + private static async Task ReadStream(IRandomAccessStreamReference r) + { + try + { + using var s = await r.OpenReadAsync(); + uint size = (uint)s.Size; + if (size == 0) return null; + using var reader = new DataReader(s); + await reader.LoadAsync(size); + var bytes = new byte[size]; + reader.ReadBytes(bytes); + return bytes; + } + catch { return null; } + } + + private GlobalSystemMediaTransportControlsSession? Cur() { lock (_lock) { return _session; } } + private void Toggle() { var s = Cur(); if (s != null) _ = s.TryTogglePlayPauseAsync(); } + private void Prev() { var s = Cur(); if (s != null) _ = s.TrySkipPreviousAsync(); } + private void Next() { var s = Cur(); if (s != null) _ = s.TrySkipNextAsync(); } + private void Stop() { var s = Cur(); if (s != null) _ = s.TryStopAsync(); } + + private void SeekBy(int secs) + { + var s = Cur(); + TimeSpan pos; bool playing; DateTime at; + lock (_lock) { pos = _pos; playing = _playing; at = _posAt; } + if (s == null) return; + var cur = playing ? pos + (DateTime.UtcNow - at) : pos; + SeekTo(s, cur + TimeSpan.FromSeconds(secs)); + } + + private void SeekTo(GlobalSystemMediaTransportControlsSession s, TimeSpan target) + { + TimeSpan start, end, lo, hi; + lock (_lock) { start = _start; end = _end; lo = _minSeek; hi = _maxSeek; } + var floor = lo > TimeSpan.Zero ? lo : start; + var ceil = hi > TimeSpan.Zero ? hi : end; + if (target < floor) target = floor; + if (ceil > TimeSpan.Zero && target > ceil) target = ceil; + lock (_lock) + { + _seekPending = target; + _seekAskedAt = DateTimeOffset.UtcNow; + _seekTries = 0; + _pos = target; + _posAt = DateTime.UtcNow; + _version++; + } + } + + private void NudgeSeek() + { + TimeSpan target, reported; + DateTimeOffset asked, sent; + int tries; + lock (_lock) + { + if (_seekPending is not { } want || _seekBusy) return; + target = want; reported = _reported; asked = _seekAskedAt; sent = _seekSentAt; tries = _seekTries; + } + + if ((reported - target).Duration() <= TimeSpan.FromSeconds(1.5)) + { + lock (_lock) _seekPending = null; + return; + } + + var now = DateTimeOffset.UtcNow; + switch (MediaTiming.NextSeekStep(tries, (now - asked).TotalMilliseconds, (now - sent).TotalMilliseconds)) + { + case MediaTiming.SeekStep.Wait: return; + case MediaTiming.SeekStep.GiveUp: lock (_lock) _seekPending = null; return; + } + + lock (_lock) { _seekSentAt = now; _seekTries = tries + 1; _seekBusy = true; } + var s = Cur(); + if (s is null) { lock (_lock) _seekBusy = false; return; } + + _ = Task.Run(async () => + { + try { await s.TryChangePlaybackPositionAsync(target.Ticks); } catch { } + lock (_lock) _seekBusy = false; + }); + } + + internal void SeekByForProbe(int secs) => SeekBy(secs); + internal TimeSpan PositionForProbe { get { lock (_lock) return _pos; } } + + internal string? ProbeLine() + { + PollTimeline(); + lock (_lock) + { + if (_title == null) return null; + static string F(TimeSpan t) => t == TimeSpan.Zero ? "0" : t.ToString(@"h\:mm\:ss"); + var t = _title.Length > 22 ? _title[..22] : _title; + + return $"{t,-22} play={(_playing ? 1 : 0)} end={F(_end),-7} pos={F(_pos),-7} " + + $"rep={F(_reported),-7} prevEnd={F(_prevEnd),-7} seek={(_seekPending is { } p ? F(p) : "-"),-7} " + + $"ring={RingProgress:0.000} accent={(_accent == Fx.White ? "WHITE (no bar!)" : _accent.ToString())} " + + $"art={(_thumb == null ? "none" : "yes")}"; + } + } + + private void SetVol(float f) { _meter.SetVolume(f); Bump(); } + private void Mute() { _meter.ToggleMute(); Bump(); } + private void Bump() { lock (_lock) { _version++; } } + + private void Seek(float f) + { + var s = Cur(); + TimeSpan start, end; lock (_lock) { start = _start; end = _end; } + if (s == null || end <= start) return; + SeekTo(s, start + TimeSpan.FromTicks((long)(Math.Clamp(f, 0f, 1f) * (end - start).Ticks))); + } + + private static (RectangleF bar, RectangleF mute) VolLayout(int w) => (new RectangleF(62, 178, 96, 20), new RectangleF(24, 172, 32, 32)); + private static RectangleF SeekRect(int w) { float tx = 180; return new RectangleF(tx, 108, w - tx - 26, 18); } + + private enum Btn { Prev, Play, Next, Back10, Fwd10, Cc } + + private static readonly double[] Rates = { 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0 }; + + private const float SpeedW = 44f, SpeedH = 22f, MenuW = 64f, ItemH = 21f, MenuPad = 5f; + private bool _speedOpen; + private float _speedT; + + private static RectangleF SpeedRect(int w) => new(w - 26f - SpeedW, 27f, SpeedW, SpeedH); + private static RectangleF MenuRect(int w) + => new(w - 26f - MenuW, 27f + SpeedH + 5f, MenuW, Rates.Length * ItemH + MenuPad * 2f); + private static RectangleF ItemRect(int w, int i) + { + var m = MenuRect(w); + return new RectangleF(m.X, m.Y + MenuPad + i * ItemH, m.Width, ItemH); + } + + private void SetRate(double r) + { + var s = Cur(); + if (s == null) return; + lock (_lock) { _rate = r; } + try { _ = s.TryChangePlaybackRateAsync(r); } catch { } + Bump(); + } + + private Btn[] Layout() + { + var app = App; + if (!IsVideo()) return new[] { Btn.Prev, Btn.Play, Btn.Next }; + bool rateOk, seekOk; lock (_lock) { rateOk = _rateEnabled; seekOk = _seekEnabled; } + var l = new List(); + if (seekOk) l.Add(Btn.Back10); + l.Add(Btn.Play); + if (seekOk) l.Add(Btn.Fwd10); + if (SubtitleKey(app) != 0) l.Add(Btn.Cc); + return l.ToArray(); + } + + private static string RateText(double r) => + (r % 1 == 0 ? ((int)r).ToString() : r.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)) + "×"; + + private bool IsVideo() + { + bool video, wide; string? title, artist; TimeSpan end; + lock (_lock) { video = _isVideo; wide = _thumbWide; title = _title; artist = _artist; end = _end; } + return video || wide || IsVideoApp(App) || HasVideoExt(title) + || (IsBrowser(App) && (string.IsNullOrEmpty(artist) || end <= TimeSpan.Zero)); + } + + private static bool ThumbIsWide(byte[]? thumb) + { + if (thumb == null || thumb.Length == 0) return false; + try + { + using var ms = new MemoryStream(thumb); + using var img = Image.FromStream(ms, useEmbeddedColorManagement: false, validateImageData: false); + return img.Height > 0 && img.Width >= img.Height * 1.4f; + } + catch { return false; } + } + + internal static string MetaLine(string? title, string? artist, string? size, string? resolution = null) + { + var parts = new List(4); + + if (!string.IsNullOrWhiteSpace(artist)) parts.Add(artist!.Trim()); + else if (Group(title) is { } grp) parts.Add(grp); + + if ((HeightLabel(resolution) ?? resolution ?? Quality(title)) is { } q) parts.Add(q); + if (Source(title) is { } src) parts.Add(src); + if (!string.IsNullOrWhiteSpace(size)) parts.Add(size!); + + return parts.Count == 0 ? "·" : string.Join(" · ", parts); + } + + private static readonly (string token, string label)[] Qualities = + { + ("2160p", "4K"), ("4320p", "8K"), ("1440p", "1440p"), ("1080p", "1080p"), ("720p", "720p"), + ("576p", "576p"), ("480p", "480p"), ("360p", "360p"), ("uhd", "4K"), + }; + internal static string? Quality(string? title) + { + if (string.IsNullOrEmpty(title)) return null; + var t = title.ToLowerInvariant(); + foreach (var (token, label) in Qualities) if (t.Contains(token)) return label; + return null; + } + + internal static string? HeightLabel(string? resolution) + { + if (string.IsNullOrWhiteSpace(resolution)) return null; + int x = resolution.IndexOf('x'); + if (x <= 0 || !int.TryParse(resolution.AsSpan(x + 1), out int hgt) || hgt <= 0) return null; + return hgt >= 4000 ? "8K" : hgt >= 2000 ? "4K" : hgt + "p"; + } + + private static readonly (string token, string label)[] Sources = + { + ("remux", "Remux"), ("bluray", "BluRay"), ("blu-ray", "BluRay"), ("brrip", "BRRip"), + ("bdrip", "BDRip"), ("web-dl", "WEB-DL"), ("webdl", "WEB-DL"), ("webrip", "WEBRip"), + ("hdtv", "HDTV"), ("dvdrip", "DVDRip"), ("hdcam", "CAM"), ("camrip", "CAM"), + }; + internal static string? Source(string? title) + { + if (string.IsNullOrEmpty(title)) return null; + var t = title.ToLowerInvariant(); + foreach (var (token, label) in Sources) if (t.Contains(token)) return label; + return null; + } + + internal static string? Group(string? title) + { + if (string.IsNullOrEmpty(title)) return null; + var name = title; + int dot = name.LastIndexOf('.'); + if (dot > 0 && name.Length - dot <= 5) name = name.Substring(0, dot); + var bits = name.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (bits.Length < 4) return null; + var last = bits[^1].Trim(); + if (last.Length is < 3 or > 18) return null; + foreach (var ch in last) if (!char.IsLetterOrDigit(ch) && ch != '-' && ch != '_') return null; + + var lower = last.ToLowerInvariant(); + if (Quality(lower) != null || Source(lower) != null) return null; + foreach (var noise in new[] { "x264", "x265", "hevc", "av1", "aac", "ac3", "dts", "mp3", "10bit" }) + if (lower == noise) return null; + return last; + } + + private string? FileFacts() + { + string? title; lock (_lock) title = _title; + var size = MediaFileInfo.Size(title, Bump); + return size is { } b ? MediaFileInfo.Human(b) : null; + } + + private static bool IsVideoApp(string app) => + app.Contains("vlc") || app.Contains("mpc") || app.Contains("mpv") || app.Contains("potplayer") + || app.Contains("wmplayer") || app.Contains("kmplayer") || app.Contains("gom") + || app.Contains("smplayer") || app.Contains("video.ui") || app.Contains("media.player"); + + private static readonly string[] VideoExt = + { ".mkv", ".mp4", ".avi", ".mov", ".webm", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".3gp", ".ogv" }; + private static bool HasVideoExt(string? title) + { + if (string.IsNullOrEmpty(title)) return false; + var t = title.ToLowerInvariant(); + foreach (var e in VideoExt) if (t.Contains(e)) return true; + return false; + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + { + + if (_speedOpen && _speedT > 0.5f) + { + var items = new List<(RectangleF, Action)>(Rates.Length); + for (int i = 0; i < Rates.Length; i++) + { + double pick = Rates[i]; + items.Add((ItemRect(w, i), _ => SetRate(pick))); + } + return items; + } + var (vbar, mute) = VolLayout(w); + var seek = SeekRect(w); + var list = new List<(RectangleF, Action)> + { + (vbar, pt => SetVol((pt.X - vbar.X) / vbar.Width)), + (mute, _ => Mute()), + }; + bool seekOk2; lock (_lock) { seekOk2 = _seekEnabled; } + if (seekOk2) list.Insert(0, (seek, pt => Seek((pt.X - seek.X) / seek.Width))); + var layout = Layout(); + var r = BtnRects(w, h, layout.Length); + for (int i = 0; i < layout.Length; i++) + { + Action act = layout[i] switch + { + Btn.Prev => Prev, + Btn.Next => Next, + Btn.Back10 => () => SeekBy(-10), + Btn.Fwd10 => () => SeekBy(10), + Btn.Cc => () => SendHotkey(SubtitleKey(App)), + _ => Toggle, + }; + list.Add((r[i], _ => act())); + } + return list; + } + + private static bool IsBrowser(string app) => + app.Contains("chrome") || app.Contains("msedge") || app.Contains("edge") || app.Contains("firefox") + || app.Contains("brave") || app.Contains("opera") || app.Contains("vivaldi"); + + private static byte SubtitleKey(string app) => + app.Contains("vlc") || app.Contains("mpv") ? (byte)'V' : (byte)0; + + private void SendHotkey(byte vk) + { + if (vk == 0) return; + string? title; lock (_lock) { title = _title; } + KeyInject.Send(PlayerWindow(App, title), vk); + } + + private static IntPtr PlayerWindow(string app, string? mediaTitle) + { + if (app.Length == 0) return IntPtr.Zero; + string hint = (mediaTitle ?? "").Trim(); + if (hint.Length > 24) hint = hint[..24]; + IntPtr first = IntPtr.Zero, matched = IntPtr.Zero; + var buf = new System.Text.StringBuilder(512); + Halo.Interop.Win32.EnumWindows((h, _) => + { + if (!Halo.Interop.Win32.IsWindowVisible(h) || Halo.Interop.Win32.GetWindowTextLengthW(h) == 0) return true; + try + { + Halo.Interop.Win32.GetWindowThreadProcessId(h, out uint pid); + using var p = System.Diagnostics.Process.GetProcessById((int)pid); + string pn = p.ProcessName.ToLowerInvariant(); + if (pn != app && !pn.Contains(app) && !app.Contains(pn)) return true; + if (first == IntPtr.Zero) first = h; + if (hint.Length >= 4) + { + buf.Clear(); + Halo.Interop.Win32.GetWindowTextW(h, buf, buf.Capacity); + if (buf.ToString().Contains(hint, StringComparison.OrdinalIgnoreCase)) { matched = h; return false; } + } + else return false; + } + catch { } + return true; + }, IntPtr.Zero); + return matched != IntPtr.Zero ? matched : first; + } + + private static RectangleF[] BtnRects(int w, int h, int n) + { + const float artX = 26, artSize = 132, size = 40, gap = 18; + float colL = artX + artSize + 22, colR = w - 26; + float cx = (colL + colR) / 2f, total = n * size + (n - 1) * gap, x0 = cx - total / 2f, y = 158; + var r = new RectangleF[n]; + for (int i = 0; i < n; i++) r[i] = new RectangleF(x0 + i * (size + gap), y, size, size); + return r; + } + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) return; + PollTimeline(); + string? title, artist; bool playing; TimeSpan pos, end, start; DateTime posAt; + lock (_lock) + { + title = _title; artist = _artist; playing = _playing; + pos = _pos; end = _end; start = _start; posAt = _posAt; + } + if (title == null) return; + + EnsureArt(); + float dt = Dt(); + + const float artX = 26, artY = 26, artSize = 132; + + Fx.Glow(g, w, h, fade, artX + artSize / 2f, artY + artSize / 2f, w * 1.35f, h * 1.9f, 38, _accent); + DrawArt(g, artX, artY, artSize, fade); + + float tx = artX + artSize + 22, tw = w - tx - 26; + bool rateOk0; lock (_lock) rateOk0 = _rateEnabled; + bool showSpeed = rateOk0 && IsVideo(); + if (showSpeed) tw -= SpeedW + 12f; + using var titleF = new Font("Segoe UI Semibold", 22f, GraphicsUnit.Pixel); + using var bodyF = new Font("Segoe UI", 15f, GraphicsUnit.Pixel); + using var timeF = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + + var titleRow = new RectangleF(tx, 34, tw, titleF.Height + 4); + titleRow.Inflate(6f, 6f); + bool onTitle = WidgetInput.Over && titleRow.Contains(WidgetInput.Mouse); + using (var tb = new SolidBrush(Mul(White, fade))) + DrawScrollingLine(g, title, titleF, tb, tx, 34, tw, onTitle, dt); + + using (var ab = new SolidBrush(Mul(Dim, fade))) + DrawLine(g, MetaLine(title, artist, FileFacts()), bodyF, ab, tx, 66, tw); + + var now = playing ? pos + (DateTime.UtcNow - posAt) : pos; + + float frac = end > start ? (float)Math.Clamp((now - start) / (end - start), 0, 1) : 0f; + int epoch; lock (_lock) epoch = _trackEpoch; + if (epoch != _shownEpoch) { _shownEpoch = epoch; _fracShown = frac; } + _fracShown = _fracShown < 0 ? frac : Ease(_fracShown, frac, dt, 0.10f); + if (Math.Abs(frac - _fracShown) < 0.0004f) _fracShown = frac; + + var seek = SeekRect(w); + var seekHit = seek; seekHit.Inflate(6f, 10f); + bool onSeek = WidgetInput.Over && seekHit.Contains(WidgetInput.Mouse); + bool seekable; lock (_lock) seekable = _seekEnabled; + if (WidgetInput.Down && !_wasDown && onSeek && seekable) _scrubbing = true; + if (_scrubbing) + { + _scrubFrac = Math.Clamp((WidgetInput.Mouse.X - seek.X) / Math.Max(1f, seek.Width), 0f, 1f); + if (!WidgetInput.Down) { Seek(_scrubFrac); _scrubbing = false; _fracShown = _scrubFrac; } + } + _seekHover = Ease(_seekHover, _scrubbing ? 1f : 0f, dt, 0.07f); + float st = _seekHover; + if (_scrubbing) _fracShown = _scrubFrac; + const float barCy = 118.5f, bhRest = 5f; + float bh = bhRest * (1f + 2f * st); + float by = barCy - bh / 2f; + Fill(g, tx, by, tw, bh, Mul(Track, fade)); + if (_fracShown > 0) Fill(g, tx, by, tw * _fracShown, bh, Mul(White, fade)); + if (end > TimeSpan.Zero) + { + using var eb = new SolidBrush(Mul(Dim, fade)); + float ty = barCy + bh / 2f + 3f; + + var span = end - start; + var shown = _scrubbing ? span * _scrubFrac : now - start; + g.DrawString(Fmt(shown), timeF, eb, tx, ty); + var ts = g.MeasureString(Fmt(span), timeF); + g.DrawString(Fmt(span), timeF, eb, tx + tw - ts.Width, ty); + } + + var (vbar, mute) = VolLayout(w); + bool muted = _meter.Muted(); + float volNow = muted ? 0f : _meter.Volume(); + _volShown = _volShown < 0 ? volNow : Ease(_volShown, volNow, dt, 0.06f); + if (Math.Abs(volNow - _volShown) < 0.002f) _volShown = volNow; + g.SmoothingMode = SmoothingMode.AntiAlias; + + var volHit = vbar; volHit.Inflate(8f, 10f); + bool onVol = WidgetInput.Over && volHit.Contains(WidgetInput.Mouse); + if (WidgetInput.Down && !_wasDown && onVol) _volScrubbing = true; + if (_volScrubbing) + { + float f = Math.Clamp((WidgetInput.Mouse.X - vbar.X) / Math.Max(1f, vbar.Width), 0f, 1f); + _volShown = f; + + if (Math.Abs(f - _volSent) > 0.004f) { SetVol(f); _volSent = f; } + if (!WidgetInput.Down) { SetVol(f); _volScrubbing = false; } + } + float vol = _volShown; + _volHover = Ease(_volHover, _volScrubbing ? 1f : 0f, dt, 0.07f); + float vt = _volHover; + _wasDown = WidgetInput.Down; + using (var fb = new SolidBrush(Mul(Color.FromArgb((int)(13 + 16 * vt), 255, 255, 255), fade))) + g.FillEllipse(fb, mute); + using (var pen = new Pen(Mul(Color.FromArgb((int)(28 + 26 * vt), 255, 255, 255), fade), 1f)) + g.DrawEllipse(pen, mute); + DrawGlyphSoft(g, mute, muted ? "\uE74F" : "\uE767", 16f, muted ? fade * 0.55f : fade * (0.8f + 0.2f * vt)); + float vy = vbar.Y + vbar.Height / 2f, bh2 = 4f * (1f + 2f * vt); + Fill(g, vbar.X, vy - bh2 / 2f, vbar.Width, bh2, Mul(Color.FromArgb(34, 255, 255, 255), fade)); + if (vol > 0) + Fill(g, vbar.X, vy - bh2 / 2f, vbar.Width * vol, bh2, + Mul(Color.FromArgb((int)(185 + 45 * vt), 255, 255, 255), fade)); + + var layout = Layout(); + var rects = BtnRects(w, h, layout.Length); + g.SmoothingMode = SmoothingMode.AntiAlias; + for (int i = 0; i < layout.Length; i++) + { + var r = rects[i]; + var hit = r; hit.Inflate(4f, 4f); + bool hov = WidgetInput.Over && hit.Contains(WidgetInput.Mouse); + _btnHover[i] += ((hov ? 1f : 0f) - _btnHover[i]) * 0.35f; + if (Math.Abs((hov ? 1f : 0f) - _btnHover[i]) < 0.03f) _btnHover[i] = hov ? 1f : 0f; + float t = _btnHover[i], sc = 1f + 0.09f * t, d = r.Width * sc; + var rr = new RectangleF(r.X + (r.Width - d) / 2f, r.Y + (r.Height - d) / 2f, d, d); + var kind = layout[i]; + bool bare = kind == Btn.Cc; + if (!bare) + { + using (var fb = new SolidBrush(Mul(Color.FromArgb((int)(15 + 19 * t), 255, 255, 255), fade))) + g.FillEllipse(fb, rr); + using (var pen = new Pen(Mul(Color.FromArgb((int)(34 + 30 * t), 255, 255, 255), fade), 1f)) + g.DrawEllipse(pen, rr); + } + float a = fade * (0.8f + 0.2f * t); + if (kind == Btn.Cc) { Fx.DrawCcMark(g, rr, a); continue; } + if (kind == Btn.Back10) { Fx.DrawSeekArrow(g, rr, forward: false, a); continue; } + if (kind == Btn.Fwd10) { Fx.DrawSeekArrow(g, rr, forward: true, a); continue; } + bool isPlay = kind == Btn.Play; + string glyph = isPlay ? Glyph(playing ? 0xE769 : 0xE768) + : kind == Btn.Prev ? Glyph(0xE892) : Glyph(0xE893); + DrawGlyphSoft(g, rr, glyph, (isPlay ? 22f : 17f) * sc, a, isPlay && !playing ? 1.5f : 0f); + } + + DrawSpeed(g, w, fade, dt, showSpeed); + } + + private void DrawSpeed(Graphics g, int w, float fade, float dt, bool show) + { + if (!show) + { + _speedOpen = false; + _speedT = Ease(_speedT, 0f, dt, 0.13f); + if (_speedT < 0.01f) { _speedT = 0f; return; } + } + var label = SpeedRect(w); + var menu = MenuRect(w); + if (show) + { + var hot = label; hot.Inflate(10f, 8f); + bool over = WidgetInput.Over + && (hot.Contains(WidgetInput.Mouse) || (_speedOpen && menu.Contains(WidgetInput.Mouse))); + _speedOpen = over; + _speedT = Ease(_speedT, over ? 1f : 0f, dt, over ? 0.075f : 0.13f); + } + + double rate; lock (_lock) rate = _rate; + g.SmoothingMode = SmoothingMode.AntiAlias; + + if (show) + { + + using var lf = new Font("Segoe UI Semibold", 13f, GraphicsUnit.Pixel); + using var lb = new SolidBrush(Mul(White, fade * (0.62f + 0.38f * _speedT))); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Far, LineAlignment = StringAlignment.Center }; + var textBox = new RectangleF(label.X, label.Y, label.Width - 11f, label.Height); + g.DrawString(RateText(rate), lf, lb, textBox, sf); + + float cx = label.Right - 5f, cy = label.Y + label.Height / 2f + 1f; + float armY = -1.6f + 3.2f * _speedT, tipY = 1.9f - 3.8f * _speedT; + using var cp = new Pen(Mul(White, fade * (0.45f + 0.4f * _speedT)), 1.4f) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawLines(cp, new[] { new PointF(cx - 3.5f, cy + armY), new PointF(cx, cy + tipY), + new PointF(cx + 3.5f, cy + armY) }); + } + + if (_speedT <= 0.01f) return; + + float a = fade * _speedT; + var m = menu; + m.Offset(0f, -9f * (1f - _speedT)); + + Fx.Glow(g, (int)(m.Right + 30f), (int)(m.Bottom + 30f), a * 0.5f, + m.X + m.Width / 2f, m.Y + m.Height * 0.35f, m.Width * 2.6f, m.Height * 1.5f, 26, + _accent == White ? Color.FromArgb(120, 150, 255) : _accent); + + using (var shade = new SolidBrush(Color.FromArgb((int)(120 * a), 10, 10, 13))) + using (var sp = Fx.Rounded(m, 15f)) + g.FillPath(shade, sp); + using (var wash = new SolidBrush(Color.FromArgb((int)(26 * a), 255, 255, 255))) + using (var wp = Fx.Rounded(m, 15f)) + g.FillPath(wash, wp); + + using (var edge = new LinearGradientBrush( + new RectangleF(m.X, m.Y - 1f, m.Width, m.Height + 2f), + Color.FromArgb((int)(74 * a), 255, 255, 255), + Color.FromArgb((int)(10 * a), 255, 255, 255), 90f)) + using (var pen = new Pen(edge, 1f)) + using (var ep = Fx.Rounded(m, 15f)) + g.DrawPath(pen, ep); + + using var itemF = new Font("Segoe UI", 13f, GraphicsUnit.Pixel); + using var isf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + for (int i = 0; i < Rates.Length; i++) + { + + float ti = Math.Clamp((_speedT - i * 0.05f) / 0.55f, 0f, 1f); + ti = 1f - MathF.Pow(1f - ti, 3); + if (ti <= 0.01f) continue; + var r = ItemRect(w, i); + r.Offset(0f, -9f * (1f - _speedT) + 5f * (1f - ti)); + + bool cur = Math.Abs(Rates[i] - rate) < 0.01; + bool hov = WidgetInput.Over && r.Contains(WidgetInput.Mouse); + + _itemHover[i] = Ease(_itemHover[i], hov ? 1f : 0f, dt, 0.055f); + float ih = _itemHover[i]; + float ia = a * ti; + + var pill = new RectangleF(r.X + 4f, r.Y + 1f, r.Width - 8f, r.Height - 2f); + if (cur) + using (var cb = new SolidBrush(Fx.Alpha(_accent == White ? White : _accent, ia * 0.20f))) + using (var cp = Fx.Rounded(pill, pill.Height / 2f)) + g.FillPath(cb, cp); + if (ih > 0.01f) + using (var hb = new SolidBrush(Color.FromArgb((int)(30 * ia * ih), 255, 255, 255))) + using (var hp = Fx.Rounded(pill, pill.Height / 2f)) + g.FillPath(hb, hp); + + using (var tb2 = new SolidBrush(Mul(White, ia * (0.58f + 0.40f * MathF.Max(cur ? 1f : 0f, ih))))) + g.DrawString(RateText(Rates[i]), itemF, tb2, r, isf); + } + } + + private readonly float[] _itemHover = new float[8]; + + private static string Glyph(int codepoint) => ((char)codepoint).ToString(); + + private readonly float[] _btnHover = new float[8]; + private float _volHover, _seekHover; + private bool _wasDown, _scrubbing, _volScrubbing; + private float _scrubFrac, _volSent = -1f; + private float _volShown = -1f, _fracShown = -1f; + private int _trackEpoch, _shownEpoch; + + private long _lastTick; + private float Dt() + { + long now = Environment.TickCount64; + float dt = _lastTick == 0 ? 1f / 60f : (now - _lastTick) / 1000f; + _lastTick = now; + return Math.Clamp(dt, 1f / 240f, 0.1f); + } + + private static float Ease(float shown, float target, float dt, float tau) + => shown + (target - shown) * (1f - MathF.Exp(-dt / tau)); + + private static readonly FontFamily FluentFamily = new("Segoe Fluent Icons"); + + private void DrawGlyphSoft(Graphics g, RectangleF r, string glyph, float px, float fade, float opticalDx = 0f) + { + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(glyph, FluentFamily, (int)FontStyle.Regular, px, PointF.Empty, sf); + path.Flatten(); + var b = path.GetBounds(); + if (b.Width <= 0 || b.Height <= 0) return; + using var m = new Matrix(); + + m.Translate(MathF.Round(r.X + (r.Width - b.Width) / 2f - b.X + opticalDx), + MathF.Round(r.Y + (r.Height - b.Height) / 2f - b.Y)); + path.Transform(m); + using var br = new SolidBrush(Mul(White, fade * 0.92f)); + g.FillPath(br, path); + } + + private void DrawArt(Graphics g, float x, float y, float size, float fade, float radius = 14f) + { + using var path = Rounded(new RectangleF(x, y, size, size), radius); + + Bitmap? img = CurArt(); + if (img == null) { string? id; lock (_lock) { id = _appId; } img = AppIcon.ForSessionApp(id); } + if (img != null) + { + CoverFill(g, img, x, y, size, path, fade); + } + else + { + using var b = new SolidBrush(Mul(Color.FromArgb(40, 255, 255, 255), fade)); + g.FillPath(b, path); + DrawGlyph(g, new RectangleF(x, y, size, size), "\uE8D6", size * 0.5f, fade * 0.7f); + } + } + + private static void CoverFill(Graphics g, Bitmap img, float x, float y, float size, GraphicsPath path, float fade) + { + int s = Math.Max(1, (int)Math.Ceiling(size)); + using var scaled = new Bitmap(s, s, PixelFormat.Format32bppPArgb); + using (var sg = Graphics.FromImage(scaled)) + { + sg.InterpolationMode = InterpolationMode.HighQualityBicubic; + sg.PixelOffsetMode = PixelOffsetMode.HighQuality; + sg.SmoothingMode = SmoothingMode.HighQuality; + using var ia = new ImageAttributes(); + ia.SetWrapMode(WrapMode.TileFlipXY); + ia.SetColorMatrix(new ColorMatrix { Matrix33 = fade }); + int side = Math.Min(img.Width, img.Height); + sg.DrawImage(img, new Rectangle(0, 0, s, s), + (img.Width - side) / 2, (img.Height - side) / 2, side, side, GraphicsUnit.Pixel, ia); + } + using var tb = new TextureBrush(scaled) { WrapMode = WrapMode.Clamp }; + tb.TranslateTransform(x, y); + g.FillPath(tb, path); + } + + private volatile bool _animatedArt; + + private volatile bool _marqueeScrolling; + + public bool Animating + { + get { lock (_lock) { return _title != null && (_playing || _animatedArt || _marqueeScrolling); } } + } + + public Color? Ring + { + get { lock (_lock) return _title != null && _end > TimeSpan.Zero ? _accent : (Color?)null; } + } + public float RingProgress + { + get + { + TimeSpan pos, end, start; bool playing; DateTime at; string? t; + lock (_lock) { pos = _pos; end = _end; start = _start; playing = _playing; at = _posAt; t = _title; } + if (t == null || end <= start) return -1f; + var now = playing ? pos + (DateTime.UtcNow - at) : pos; + return (float)Math.Clamp((now - start) / (end - start), 0, 1); + } + } + + private long _pillTick; + private float PillDt() + { + long now = Environment.TickCount64; + float dt = _pillTick == 0 ? 1f / 60f : Math.Clamp((now - _pillTick) / 1000f, 1f / 240f, 0.1f); + _pillTick = now; + return dt; + } + + private static readonly Color NeutralBar = Color.FromArgb(255, 222, 226, 232); + + private Color _accentShown = Fx.White; + private bool _accentInit; + + private float _barIn; + private float _lastProg = -1f; + + private static Color LerpColor(Color from, Color to, float dt, float tau) + { + float k = 1f - MathF.Exp(-dt / tau); + return Color.FromArgb( + (int)MathF.Round(from.A + (to.A - from.A) * k), + (int)MathF.Round(from.R + (to.R - from.R) * k), + (int)MathF.Round(from.G + (to.G - from.G) * k), + (int)MathF.Round(from.B + (to.B - from.B) * k)); + } + + private float _pillFrac = -1f; + private int _pillEpoch = -1; + private float PillFrac(float frac, float dt) + { + if (frac < 0f) { _pillFrac = -1f; return frac; } + int epoch; lock (_lock) epoch = _trackEpoch; + + if (epoch != _pillEpoch || _pillFrac < 0f || Math.Abs(frac - _pillFrac) > 0.08f) + { + _pillEpoch = epoch; + return _pillFrac = frac; + } + _pillFrac = Ease(_pillFrac, frac, dt, 0.14f); + if (Math.Abs(frac - _pillFrac) < 0.0004f) _pillFrac = frac; + return _pillFrac; + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + PollTimeline(); + string? title; bool playing; + lock (_lock) { title = _title; playing = _playing; } + if (title == null) return; + EnsureArt(); + float sz = h - 14f, x = 9, y = (h - sz) / 2f; + float dt = PillDt(); + + float prog = Halo.Settings.SettingsStore.On("media.progress") + ? PillFrac(RingProgress, dt) : -1f; + + var accentTarget = _accent == Fx.White ? NeutralBar : _accent; + if (!_accentInit) { _accentShown = accentTarget; _accentInit = true; } + else _accentShown = LerpColor(_accentShown, accentTarget, dt, 0.30f); + + if (prog >= 0f) _lastProg = prog; + _barIn = Ease(_barIn, prog >= 0f ? 1f : 0f, dt, 0.20f); + if (_barIn > 0.01f && _lastProg >= 0f) + + Fx.PillBar(g, w, h, fade * _barIn, _lastProg, _accentShown, 0.5f); + Fx.Glow(g, w, h, fade, x + sz / 2f, h / 2f, w * 0.7f, h * 2.2f, 34, _accent); + DrawArt(g, x, y, sz, fade, sz * 0.28f); + + DrawEqualizer(g, w - 14f, h / 2f, fade, playing); + } + + private const int EqBars = 9; + private readonly AudioMeter _meter = new(); + private readonly float[] _eq = new float[EqBars]; + private float _amp; + + private void DrawEqualizer(Graphics g, float rightX, float cy, float fade, bool playing) + { + const float barW = 2.6f, gap = 2.6f, maxH = 22f, minH = 2.6f; + float totalW = EqBars * barW + (EqBars - 1) * gap; + float x0 = rightX - totalW; + + float[]? bands = playing ? AudioSpectrum.Bands() : null; + bool live = bands != null && AudioSpectrum.Available; + float peak = playing ? _meter.Peak() : 0f; + _amp += (Math.Clamp((float)Math.Sqrt(peak) * 1.4f, 0f, 1f) - _amp) * 0.22f; + double t = Environment.TickCount / 1000.0; + + for (int i = 0; i < EqBars; i++) + { + float target; + if (live) + { + target = minH + (maxH - minH) * bands![i]; + } + else + { + + float env = 0.25f + 0.75f * (float)Math.Sin(Math.PI * (i + 0.5) / EqBars); + float phase = 0.5f + 0.5f * (float)Math.Sin(t * (1.7 + i * 0.4) + i * 1.9); + target = minH + (maxH - minH) * _amp * env * (0.35f + 0.65f * phase); + } + + float rise = live ? 0.80f : 0.35f, fall = live ? 0.32f : 0.12f; + _eq[i] += (target - _eq[i]) * (target > _eq[i] ? rise : fall); + float bh = Math.Max(minH, _eq[i]); + Color col = playing ? PaletteAt((float)i / (EqBars - 1)) : Color.FromArgb(120, 255, 255, 255); + Fill(g, x0 + i * (barW + gap), cy - bh / 2f, barW, bh, Mul(col, fade)); + } + } + + private Color[] _palette = { White, White, White }; + + private static Color[] Palette(Color accent) + { + Fx.RgbToHsv(accent, out float h, out float s, out float v); + return new[] { Fx.HsvToRgb((h - 22f + 360f) % 360f, s, v), accent, Fx.HsvToRgb((h + 22f) % 360f, s, v) }; + } + + private Color PaletteAt(float f) + { + f = Math.Clamp(f, 0f, 1f); + return f <= 0.5f ? LerpColor(_palette[0], _palette[1], f * 2f) : LerpColor(_palette[1], _palette[2], (f - 0.5f) * 2f); + } + + private static Color LerpColor(Color a, Color b, float t) + => Color.FromArgb(255, (int)(a.R + (b.R - a.R) * t), (int)(a.G + (b.G - a.G) * t), (int)(a.B + (b.B - a.B) * t)); + + private void DrawGlyph(Graphics g, RectangleF r, string glyph, float px, float fade) + { + using var f = new Font("Segoe Fluent Icons", px, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade)); + + Fx.GlyphCentred(g, r, glyph, f, b); + } + + private static (Bitmap[]? frames, int[]? delays) DecodeFrames(byte[]? bytes) + { + if (bytes == null || bytes.Length == 0) return (null, null); + try + { + using var ms = new MemoryStream(bytes); + using var img = Image.FromStream(ms); + int n = 1; + try { n = img.GetFrameCount(FrameDimension.Time); } catch { } + if (n <= 1) return (new[] { new Bitmap(img) }, new[] { 0 }); + + var frames = new Bitmap[n]; + var delays = new int[n]; + byte[]? pd = null; + try { pd = img.GetPropertyItem(0x5100)?.Value; } catch { } + for (int i = 0; i < n; i++) + { + img.SelectActiveFrame(FrameDimension.Time, i); + frames[i] = new Bitmap(img); + int cs = pd != null && pd.Length >= (i + 1) * 4 ? BitConverter.ToInt32(pd, i * 4) : 10; + delays[i] = Math.Max(20, cs * 10); + } + return (frames, delays); + } + catch { return (null, null); } + } + + private static void DrawLine(Graphics g, string text, Font f, Brush b, float x, float y, float w) + { + using var sf = new StringFormat(StringFormatFlags.NoWrap) { Trimming = StringTrimming.EllipsisCharacter }; + if (IsRtl(text)) sf.FormatFlags |= StringFormatFlags.DirectionRightToLeft; + g.DrawString(text, f, b, new RectangleF(x, y, w, f.Height + 4), sf); + } + + private float _marquee; + private float _marqueeHold; + + internal const float MarqueeGap = 48f, MarqueeSpeed = 42f, MarqueeHold = 0.35f; + + internal static (float offset, float hold) MarqueeStep(float offset, float hold, float dt, float span) + { + if (span <= 0f) return (0f, 0f); + if (hold < MarqueeHold) return (offset, hold + dt); + offset += MarqueeSpeed * dt; + return offset >= span ? (offset - span, 0f) : (offset, hold); + } + + private void DrawScrollingLine(Graphics g, string text, Font f, Brush b, float x, float y, float w, + bool hovered, float dt) + { + float textW = g.MeasureString(text, f, int.MaxValue, StringFormat.GenericTypographic).Width; + if (textW <= w || !hovered) + { + + if (!hovered) { _marquee = 0f; _marqueeHold = 0f; } + _marqueeScrolling = false; + DrawLine(g, text, f, b, x, y, w); + return; + } + _marqueeScrolling = true; + + float span = textW + MarqueeGap; + (_marquee, _marqueeHold) = MarqueeStep(_marquee, _marqueeHold, dt, span); + + var state = g.Save(); + g.SetClip(new RectangleF(x, y, w, f.Height + 4)); + bool rtl = IsRtl(text); + using var sf = new StringFormat(StringFormatFlags.NoWrap); + if (rtl) sf.FormatFlags |= StringFormatFlags.DirectionRightToLeft; + float h2 = f.Height + 4; + for (int pass = 0; pass < 2; pass++) + { + + float ox = rtl ? x + w - textW + (_marquee - pass * span) + : x - (_marquee - pass * span); + g.DrawString(text, f, b, new RectangleF(ox, y, textW + 2, h2), sf); + } + g.Restore(state); + } + + private static bool IsRtl(string s) + { + foreach (var c in s) + if (c >= 0x0590 && c <= 0x08FF) return true; + return false; + } + + private static string Fmt(TimeSpan t) + => t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}"; + + private static void Fill(Graphics g, float x, float y, float w, float h, Color c) + { + if (w <= 0) return; + using var path = Rounded(new RectangleF(x, y, w, h), h / 2f); + using var b = new SolidBrush(c); + g.FillPath(b, path); + } + + private static GraphicsPath Rounded(RectangleF r, float radius) + { + float d = Math.Min(radius * 2, Math.Min(r.Width, r.Height)); + var p = new GraphicsPath(); + if (d <= 0) { p.AddRectangle(r); return p; } + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + + private static Color Mul(Color c, float a) + => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); +} + +internal static class MediaTiming +{ + internal const int BurstMs = 320; + internal const int FreshMs = 1200; + internal const int RetryMs = 700; + internal const int GiveUpMs = 2500; + internal const int MaxTries = 2; + internal const int LeftoverMs = 2000; + + internal enum SeekStep { Wait, Send, GiveUp } + + internal static SeekStep NextSeekStep(int tries, double msSinceAsked, double msSinceSent) + { + + if (tries == 0) + return msSinceSent < FreshMs && msSinceAsked < BurstMs ? SeekStep.Wait : SeekStep.Send; + + if (tries >= MaxTries || msSinceAsked > GiveUpMs) return SeekStep.GiveUp; + return msSinceSent < RetryMs ? SeekStep.Wait : SeekStep.Send; + } + + internal static bool IsLeftover(TimeSpan incomingEnd, TimeSpan prevEnd, double msSinceTrack) + => prevEnd > TimeSpan.Zero && incomingEnd == prevEnd && msSinceTrack < LeftoverMs; + + internal static bool IsBlank(TimeSpan inStart, TimeSpan inEnd, TimeSpan knownStart, TimeSpan knownEnd) + => inEnd <= inStart && knownEnd > knownStart; + + internal static bool ShouldRestamp(bool repeated, bool playing, bool confirming) + => !repeated || !playing || confirming; +} diff --git a/src/Halo.App/Widgets/NotifBanner.cs b/src/Halo.App/Widgets/NotifBanner.cs new file mode 100644 index 0000000..5516f03 Binary files /dev/null and b/src/Halo.App/Widgets/NotifBanner.cs differ diff --git a/src/Halo.App/Widgets/PartialFiles.cs b/src/Halo.App/Widgets/PartialFiles.cs new file mode 100644 index 0000000..5166363 --- /dev/null +++ b/src/Halo.App/Widgets/PartialFiles.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace Halo.Widgets; + +internal static class PartialFiles +{ + + private static readonly string[] Suffixes = + { ".crdownload", ".opdownload", ".partial", ".download", ".aria2", ".part", ".!ut", ".!qb" }; + + private const long MinSize = 128 * 1024; + private const int StaleSeconds = 20; + + internal readonly record struct Sample(string Path, string Name, long Bytes, long GrowthPerSec, int OwnerPid, bool Stalled); + + private const int StallSamples = 2; + + private static readonly Dictionary _seen = + new(StringComparer.OrdinalIgnoreCase); + + public static bool IsPartial(string path, out string cleanName) + { + cleanName = ""; + if (string.IsNullOrEmpty(path)) return false; + string file = Path.GetFileName(path); + foreach (var s in Suffixes) + if (file.EndsWith(s, StringComparison.OrdinalIgnoreCase)) + { + cleanName = file.Substring(0, file.Length - s.Length); + + if (cleanName.StartsWith("Unconfirmed ", StringComparison.OrdinalIgnoreCase)) cleanName = ""; + return true; + } + return false; + } + + private static IEnumerable Roots() + { + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var d in Prepend(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) + { + string full; + try { full = Path.GetFullPath(d).TrimEnd('\\'); } catch { continue; } + if (seen.Add(full)) yield return full; + } + } + + private static IEnumerable Prepend(string profile) + { + yield return Path.Combine(profile, "Downloads"); + foreach (var d in Downloaders.Directories()) yield return d; + } + + public static int LiveCount { get; private set; } + + public static Sample[] All() + { + var found = new List(); + var now = DateTime.UtcNow; + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var root in Roots()) + { + if (!Directory.Exists(root)) continue; + foreach (var path in Enumerate(root)) + { + if (!IsPartial(path, out string clean)) continue; + long len; DateTime touched; + try { var fi = new FileInfo(path); len = fi.Length; touched = fi.LastWriteTimeUtc; } + catch { continue; } + if (len < MinSize) continue; + if ((now - touched).TotalSeconds > StaleSeconds) continue; + + if (!live.Add(path)) continue; + + long rate = 0; + int flat = 0; + if (_seen.TryGetValue(path, out var prev)) + { + double secs = (now - prev.at).TotalSeconds; + if (secs >= 0.5) + { + long grew = len - prev.bytes; + if (grew > 0) rate = (long)(grew / secs); + flat = grew > 0 ? 0 : prev.flat + 1; + _seen[path] = (len, now, flat); + } + else { flat = prev.flat; rate = prev.bytes == len ? 0 : 1; } + } + else _seen[path] = (len, now, 0); + + int pid = OwnerPid(path); + if (pid != 0) Downloaders.Learn(pid, Path.GetDirectoryName(path)); + found.Add(new Sample(path, clean, len, rate, pid, flat >= StallSamples)); + } + } + + if (_seen.Count > 64) + foreach (var k in new List(_seen.Keys)) + if (!live.Contains(k)) _seen.Remove(k); + } + catch { } + + LiveCount = found.Count; + return found.ToArray(); + } + + private static IEnumerable Enumerate(string root) + { + try { return Directory.EnumerateFiles(root, "*", SearchOption.TopDirectoryOnly); } + catch { return Array.Empty(); } + } + + public static int OwnerPid(string path) + { + uint session = 0; + var key = new StringBuilder(CCH_RM_SESSION_KEY + 1); + try + { + if (RmStartSession(out session, 0, key) != 0) return 0; + if (RmRegisterResources(session, 1, new[] { path }, 0, IntPtr.Zero, 0, null) != 0) return 0; + uint count = 0; + int rc = RmGetList(session, out uint needed, ref count, null, out _); + if (needed == 0 || (rc != 0 && rc != ERROR_MORE_DATA)) return 0; + var infos = new RM_PROCESS_INFO[needed]; + count = needed; + if (RmGetList(session, out _, ref count, infos, out _) != 0) return 0; + for (int i = 0; i < count; i++) + { + int pid = (int)infos[i].Process.dwProcessId; + if (pid != 0 && pid != Environment.ProcessId) return pid; + } + return 0; + } + catch { return 0; } + finally { if (session != 0) { try { RmEndSession(session); } catch { } } } + } + + private const int CCH_RM_SESSION_KEY = 32, ERROR_MORE_DATA = 234; + + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME { public uint dwLowDateTime, dwHighDateTime; } + + [StructLayout(LayoutKind.Sequential)] + private struct RM_UNIQUE_PROCESS { public uint dwProcessId; public FILETIME ProcessStartTime; } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct RM_PROCESS_INFO + { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string strServiceShortName; + public int ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; + } + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + private static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, StringBuilder strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + private static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFilenames, + uint nApplications, IntPtr rgApplications, uint nServices, string[]? rgsServiceNames); + [DllImport("rstrtmgr.dll")] + private static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, + [In, Out] RM_PROCESS_INFO[]? rgAffectedApps, out uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + private static extern int RmEndSession(uint pSessionHandle); +} diff --git a/src/Halo.App/Widgets/Privacy.cs b/src/Halo.App/Widgets/Privacy.cs new file mode 100644 index 0000000..02724c8 --- /dev/null +++ b/src/Halo.App/Widgets/Privacy.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using Microsoft.Win32; + +namespace Halo.Widgets; + +internal static class Privacy +{ + public static volatile bool Mic, Cam; + public static int Version; + public static bool Active => Mic || Cam; + + private const string Base = + @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\"; + private static Timer? _timer; + + public static void Poke() => _timer ??= new Timer(_ => Scan(), null, 800, 1200); + + private static void Scan() + { + try + { + bool mic = InUse("microphone"), cam = InUse("webcam"); + if (mic == Mic && cam == Cam) return; + Mic = mic; Cam = cam; + Interlocked.Increment(ref Version); + } + catch { } + } + + private static bool InUse(string capability) + { + using var root = Registry.CurrentUser.OpenSubKey(Base + capability); + return root != null && AnyLive(root, 0); + } + + private static readonly string[] Ignore = { "pythonw.exe" }; + + private static bool AnyLive(RegistryKey key, int depth) + { + if (key.GetValue("LastUsedTimeStop") is long stop && stop == 0) return true; + if (depth >= 3) return false; + foreach (var name in key.GetSubKeyNames()) + { + bool skip = false; + foreach (var ig in Ignore) + if (name.EndsWith(ig, StringComparison.OrdinalIgnoreCase)) { skip = true; break; } + if (skip) continue; + using var sub = key.OpenSubKey(name); + if (sub != null && AnyLive(sub, depth + 1)) return true; + } + return false; + } +} diff --git a/src/Halo.App/Widgets/Script.cs b/src/Halo.App/Widgets/Script.cs new file mode 100644 index 0000000..21743ff --- /dev/null +++ b/src/Halo.App/Widgets/Script.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace Halo.Widgets; + +internal static class Script +{ + + internal readonly record struct Glyph(GraphicsPath[] Strokes, float[] Lengths, float Advance, float Length); + + private const float Space = 17f, Track = 9f; + + private static readonly Dictionary Hand = new() + { + ['h'] = (34f, [ + [0, -78, 1, -55, 3, -28, 4, 0], + [4, -6, 8, -30, 17, -33, 24, -33, 31, -33, 32, -22, 32, -12, 32, -6, 32, -2, 34, 1], + ]), + ['a'] = (31f, [ + [26, -30, 20, -37, 7, -36, 5, -21, 3, -7, 12, -1, 19, -6, 24, -10, 26, -21, 26, -30], + [26, -30, 26, -18, 26, -7, 29, 1], + ]), + ['l'] = (16f, [[3, -78, 2, -50, 0, -22, 2, -7, 3, -1, 7, 1, 12, -2]]), + ['o'] = (30f, [[26, -19, 27, -31, 16, -37, 9, -31, 1, -24, 2, -6, 11, -2, 20, 2, 27, -8, 26, -19]]), + ['i'] = (15f, [ + [4, -34, 2, -22, 1, -10, 3, -3, 5, 0, 9, 1, 13, -3], + [5, -47, 6, -47, 7, -46, 6, -45], + ]), + ['\''] = (8f, [[3, -78, 5, -72, 4, -66, 1, -61]]), + ['m'] = (48f, [ + [1, -33, 0, -20, 0, -9, 1, 0], + [1, -8, 3, -28, 9, -34, 15, -34, 21, -34, 22, -24, 22, -13, 22, -6, 22, -2, 22, 0], + [22, -9, 24, -28, 30, -34, 36, -34, 43, -34, 44, -23, 44, -12, 44, -6, 44, -2, 46, 1], + ]), + ['w'] = (43f, [[0, -33, 2, -14, 7, -3, 11, 1, 15, -3, 18, -19, 20, -30, + 22, -19, 25, -3, 29, 1, 33, -3, 39, -16, 42, -33]]), + ['e'] = (29f, [[2, -13, 10, -15, 19, -18, 27, -21, 29, -31, 22, -36, 14, -35, + 4, -34, -1, -22, 3, -11, 7, -1, 19, 2, 27, -6]]), + ['c'] = (28f, [[26, -26, 22, -34, 9, -36, 4, -23, -1, -10, 6, 1, 15, 1, 20, 1, 24, -3, 27, -7]]), + }; + + private static Dictionary? _built; + + private static Dictionary Built() + { + if (_built is not null) return _built; + var map = new Dictionary(); + foreach (var (c, (advance, strokes)) in Hand) + { + var paths = new GraphicsPath[strokes.Length]; + var lens = new float[strokes.Length]; + float total = 0f; + for (int k = 0; k < strokes.Length; k++) + { + var s = strokes[k]; + var path = new GraphicsPath(); + var cur = new PointF(s[0], s[1]); + for (int i = 2; i + 5 < s.Length; i += 6) + { + path.AddBezier(cur, new PointF(s[i], s[i + 1]), new PointF(s[i + 2], s[i + 3]), + new PointF(s[i + 4], s[i + 5])); + cur = new PointF(s[i + 4], s[i + 5]); + } + paths[k] = path; + lens[k] = Measure(path); + total += lens[k]; + } + map[c] = new Glyph(paths, lens, advance, total); + } + _built = map; + return map; + } + + private static float Measure(GraphicsPath p) + { + using var probe = (GraphicsPath)p.Clone(); + probe.Flatten(null, 0.15f); + var pts = probe.PathPoints; + var kinds = probe.PathTypes; + float len = 0f; + for (int i = 1; i < pts.Length; i++) + { + if ((kinds[i] & 0x7) == 0) continue; + len += MathF.Sqrt(MathF.Pow(pts[i].X - pts[i - 1].X, 2) + MathF.Pow(pts[i].Y - pts[i - 1].Y, 2)); + } + return MathF.Max(len, 0.001f); + } + + internal static IEnumerable<(char Char, int Stroke, int Numbers)> Strokes() + { + foreach (var (c, (_, strokes)) in Hand) + for (int i = 0; i < strokes.Length; i++) + yield return (c, i, strokes[i].Length); + } + + internal static bool Can(string text) + { + var map = Built(); + foreach (char c in text) + if (c != ' ' && !map.ContainsKey(char.ToLowerInvariant(c))) return false; + return true; + } + + internal static float Width(string text) + { + var map = Built(); + float w = 0f; + foreach (char c in text) + w += c == ' ' ? Space : (map.TryGetValue(char.ToLowerInvariant(c), out var gl) ? gl.Advance : 0f) + Track; + return MathF.Max(w, 1f); + } + + internal static void Draw(Graphics g, string text, RectangleF box, float written, float alpha, + Color ink, float weight) + { + if (alpha <= 0.004f || written <= 0f || string.IsNullOrEmpty(text)) return; + var map = Built(); + + float total = 0f; + foreach (char c in text) + if (c != ' ' && map.TryGetValue(char.ToLowerInvariant(c), out var gl)) total += gl.Length; + if (total <= 0f) return; + + float unitsW = Width(text); + const float Top = -80f, Bottom = 6f; + float scale = MathF.Min(box.Width / (unitsW + weight), box.Height / (Bottom - Top + weight)); + + var save = g.Save(); + try + { + g.TranslateTransform(box.X + box.Width / 2f, box.Y + box.Height / 2f); + g.ScaleTransform(scale, scale); + g.TranslateTransform(-unitsW / 2f, -(Top + Bottom) / 2f); + + using var pen = new Pen(Color.FromArgb((int)(Math.Clamp(alpha, 0f, 1f) * ink.A), ink), weight) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + LineJoin = LineJoin.Round, + DashCap = DashCap.Round, + }; + + float want = total * written, done = 0f, x = 0f; + foreach (char c in text) + { + if (c == ' ') { x += Space; continue; } + if (!map.TryGetValue(char.ToLowerInvariant(c), out var gl)) continue; + if (done >= want) break; + + var st = g.Save(); + g.TranslateTransform(x, 0f); + + for (int k = 0; k < gl.Strokes.Length && done < want; k++) + { + float len = gl.Lengths[k]; + float here = Math.Clamp((want - done) / len, 0f, 1f); + if (here < 1f) + { + + pen.DashPattern = [MathF.Max(0.001f, len * here / weight), len * 2f / weight]; + } + else pen.DashStyle = DashStyle.Solid; + g.DrawPath(pen, gl.Strokes[k]); + done += len; + } + g.Restore(st); + + x += gl.Advance + Track; + } + } + finally { g.Restore(save); } + } +} diff --git a/src/Halo.App/Widgets/SteamInstall.cs b/src/Halo.App/Widgets/SteamInstall.cs new file mode 100644 index 0000000..fd2e64f --- /dev/null +++ b/src/Halo.App/Widgets/SteamInstall.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Halo.Widgets; + +internal static class SteamInstall +{ + private const long MinBytes = 1024 * 1024; + private const int StaleSeconds = 90; + + internal readonly record struct Item(string Name, long Done, long Total); + + private static readonly object _lock = new(); + private static string[]? _libs; + private static DateTime _libsAt = DateTime.MinValue; + + public static Item? Current() + { + try + { + Item? best = null; + long bestOutstanding = 0; + var now = DateTime.UtcNow; + foreach (var lib in Libraries()) + { + string apps = Path.Combine(lib, "steamapps"); + if (!Directory.Exists(apps)) continue; + string[] files; + try { files = Directory.GetFiles(apps, "appmanifest_*.acf"); } catch { continue; } + foreach (var f in files) + { + try { if ((now - File.GetLastWriteTimeUtc(f)).TotalSeconds > StaleSeconds) continue; } + catch { continue; } + if (!Parse(SafeRead(f), out var item)) continue; + long outstanding = item.Total - item.Done; + if (outstanding <= 0 || item.Total < MinBytes) continue; + if (best is null || outstanding > bestOutstanding) { best = item; bestOutstanding = outstanding; } + } + } + return best; + } + catch { return null; } + } + + private static string SafeRead(string path) + { + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var sr = new StreamReader(fs); + return sr.ReadToEnd(); + } + catch { return ""; } + } + + internal static bool Parse(string text, out Item item) + { + item = default; + if (string.IsNullOrEmpty(text)) return false; + string name = ""; + long done = -1, total = -1; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] != '"') continue; + if (!Kv(line, out string key, out string val)) continue; + switch (key) + { + case "name": if (name.Length == 0) name = val; break; + case "BytesDownloaded": long.TryParse(val, out done); break; + case "BytesToDownload": long.TryParse(val, out total); break; + } + } + if (total <= 0 || done < 0) return false; + item = new Item(name.Length > 0 ? name : "Steam game", done, Math.Max(done, total)); + return true; + } + + private static bool Kv(string line, out string key, out string value) + { + key = value = ""; + int k0 = line.IndexOf('"'); + if (k0 < 0) return false; + int k1 = line.IndexOf('"', k0 + 1); + if (k1 < 0) return false; + int v0 = line.IndexOf('"', k1 + 1); + if (v0 < 0) return false; + int v1 = line.IndexOf('"', v0 + 1); + if (v1 < 0) return false; + key = line.Substring(k0 + 1, k1 - k0 - 1); + value = line.Substring(v0 + 1, v1 - v0 - 1); + return true; + } + + private static string[] Libraries() + { + lock (_lock) + if (_libs != null && (DateTime.UtcNow - _libsAt).TotalMinutes < 5) return _libs; + + var found = new List(); + try + { + string? steam = SteamPath(); + if (steam != null) + { + found.Add(steam); + string vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf"); + if (File.Exists(vdf)) + foreach (var p in ParseLibraries(SafeRead(vdf))) + { + bool dup = false; + foreach (var have in found) + if (string.Equals(have, p, StringComparison.OrdinalIgnoreCase)) { dup = true; break; } + if (!dup) found.Add(p); + } + } + } + catch { } + + var arr = found.ToArray(); + lock (_lock) { _libs = arr; _libsAt = DateTime.UtcNow; } + return arr; + } + + internal static List ParseLibraries(string vdf) + { + var list = new List(); + if (string.IsNullOrEmpty(vdf)) return list; + foreach (var raw in vdf.Split('\n')) + { + var line = raw.Trim(); + if (!line.StartsWith("\"path\"", StringComparison.OrdinalIgnoreCase)) continue; + if (!Kv(line, out _, out string val)) continue; + string path = val.Replace("\\\\", "\\"); + if (path.Length > 0) list.Add(path); + } + return list; + } + + private static string? SteamPath() + { + try + { + using var k = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Valve\Steam"); + string? p = k?.GetValue("SteamPath") as string; + + return string.IsNullOrEmpty(p) ? null : Path.GetFullPath(p!.Replace('/', '\\')); + } + catch { return null; } + } +} diff --git a/src/Halo.App/Widgets/StoreInstall.cs b/src/Halo.App/Widgets/StoreInstall.cs new file mode 100644 index 0000000..eeec509 --- /dev/null +++ b/src/Halo.App/Widgets/StoreInstall.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using Windows.ApplicationModel.Store.Preview.InstallControl; + +namespace Halo.Widgets; + +internal static class StoreInstall +{ + private static AppInstallManager? _mgr; + private static AppInstallItem? _item; + private static readonly object _lock = new(); + + internal enum Phase { None, Waiting, Downloading, Installing, Paused } + + private static int _lastPct; private static long _lastDone, _lastTotal; + + private static string? _waitPfn; private static long _waitSinceMs; + private const long WaitGraceMs = 30_000; + + public static Phase Poll(out string name, out int pct, out long done, out long total) + { + name = "Store app"; pct = 0; done = 0; total = 0; + try + { + _mgr ??= new AppInstallManager(); + AppInstallItem? active = null; + AppInstallStatus? st = null; + + IReadOnlyList list; + try { list = _mgr.AppInstallItemsWithGroupSupport; } + catch { list = _mgr.AppInstallItems; } + + int bestRank = -1; + foreach (var it in list) + { + AppInstallStatus s; + try { s = it.GetCurrentStatus(); } catch { continue; } + var state = s.InstallState; + if (state is AppInstallState.Completed or AppInstallState.Canceled or AppInstallState.Error) continue; + int rank = state switch + { + AppInstallState.Paused or AppInstallState.PausedLowBattery + or AppInstallState.PausedWiFiRecommended or AppInstallState.PausedWiFiRequired => 1, + AppInstallState.Pending or AppInstallState.ReadyToDownload => -1, + _ => 2, + }; + if (rank < 0) continue; + if (rank > bestRank) { bestRank = rank; active = it; st = s; } + if (rank == 2) break; + } + if (active == null || st == null) { lock (_lock) _item = null; return Phase.None; } + lock (_lock) _item = active; + + pct = (int)Math.Clamp(st.PercentComplete, 0, 100); + done = (long)st.BytesDownloaded; + total = (long)st.DownloadSizeInBytes; + name = FriendlyName(active.PackageFamilyName); + var phase = st.InstallState switch + { + AppInstallState.Paused or AppInstallState.PausedLowBattery + or AppInstallState.PausedWiFiRecommended or AppInstallState.PausedWiFiRequired => Phase.Paused, + AppInstallState.Downloading => Phase.Downloading, + AppInstallState.Pending or AppInstallState.ReadyToDownload => Phase.Waiting, + _ => Phase.Installing, + }; + + if (phase == Phase.Waiting) + { + long nowMs = Environment.TickCount64; + if (_waitPfn != active.PackageFamilyName) { _waitPfn = active.PackageFamilyName; _waitSinceMs = nowMs; } + else if (nowMs - _waitSinceMs > WaitGraceMs) { lock (_lock) _item = null; return Phase.None; } + } + else _waitPfn = null; + + if (phase == Phase.Paused && pct == 0 && _lastPct > 0) { pct = _lastPct; done = _lastDone; total = _lastTotal; } + else if (phase != Phase.Waiting) { _lastPct = pct; _lastDone = done; _lastTotal = total; } + return phase; + } + catch { lock (_lock) _item = null; return Phase.None; } + } + + public static void Pause() { try { lock (_lock) _item?.Pause(); } catch { } } + public static void Resume() { try { lock (_lock) _item?.Restart(); } catch { } } + public static void Cancel() { try { lock (_lock) _item?.Cancel(); } catch { } } + + private static string FriendlyName(string pfn) + { + if (string.IsNullOrEmpty(pfn)) return "Store app"; + int us = pfn.IndexOf('_'); + string s = us > 0 ? pfn[..us] : pfn; + int dot = s.LastIndexOf('.'); + return dot >= 0 && dot < s.Length - 1 ? s[(dot + 1)..] : s; + } +} diff --git a/src/Halo.App/Widgets/VlcHttp.cs b/src/Halo.App/Widgets/VlcHttp.cs new file mode 100644 index 0000000..c14e905 --- /dev/null +++ b/src/Halo.App/Widgets/VlcHttp.cs @@ -0,0 +1,163 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Halo.Widgets; + +internal static class VlcHttp +{ + public static volatile bool Online; + public static double Rate = 1.0; + + public static int Time; + public static int Length; + public static volatile string? Resolution; + private static long _seekSentAt; + private static int _seekTarget = -1; + public static volatile bool Playing = true; + public static volatile bool SubsOn = true; + private static string _lastPlid = ""; + + private const int Port = 8080; + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMilliseconds(800) }; + private static volatile bool _configured; + + private static string RcPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vlc", "vlcrc"); + + public static void EnsureConfigured() + { + try + { + string path = RcPath; + if (!File.Exists(path)) return; + string text = File.ReadAllText(path); + string? pw = ReadKey(text, "http-password"); + bool httpOn = (ReadKey(text, "extraintf") ?? "").Split(':').Contains("http"); + if (httpOn && !string.IsNullOrEmpty(pw)) { Arm(pw!); return; } + + pw = string.IsNullOrEmpty(pw) ? Convert.ToHexString(RandomNumberGenerator.GetBytes(8)) : pw; + text = SetKey(text, "extraintf", "http"); + text = SetKey(text, "http-host", "127.0.0.1"); + text = SetKey(text, "http-port", Port.ToString(CultureInfo.InvariantCulture)); + text = SetKey(text, "http-password", pw); + File.WriteAllText(path, text); + Arm(pw); + } + catch { } + } + + private static void Arm(string pw) + { + Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pw))); + _configured = true; + } + + internal static string? ReadKey(string vlcrc, string key) + { + var m = Regex.Match(vlcrc, $@"(?m)^\s*{Regex.Escape(key)}\s*=\s*(.*)$"); + return m.Success ? m.Groups[1].Value.Trim() : null; + } + + internal static string SetKey(string vlcrc, string key, string value) + { + var rx = new Regex($@"(?m)^\s*#?\s*{Regex.Escape(key)}\s*=.*$"); + return rx.IsMatch(vlcrc) + ? rx.Replace(vlcrc, $"{key}={value}", 1) + : vlcrc.TrimEnd('\r', '\n') + $"\n{key}={value}\n"; + } + + public static void Poll() + { + if (!_configured) { Online = false; return; } + try + { + string xml = Get("/requests/status.xml"); + var (rate, playing) = ParseStatus(xml); + Rate = rate; Playing = playing; + var (time, length) = ParseTime(xml); + Length = length; + + bool settled = _seekTarget < 0 || Math.Abs(time - _seekTarget) <= 2 + || Environment.TickCount64 - _seekSentAt > 1500; + if (settled) { Time = time; _seekTarget = -1; } + Resolution = ParseResolution(xml); + + string plid = Regex.Match(xml, @"(-?\d+)").Groups[1].Value; + if (plid != _lastPlid) { _lastPlid = plid; SubsOn = xml.Contains(">Subtitle<"); } + Online = true; + } + catch { Online = false; } + } + + internal static (int time, int length) ParseTime(string xml) + { + int time = -1, length = 0; + var mt = Regex.Match(xml, @""); + if (mt.Success) int.TryParse(mt.Groups[1].Value, out time); + var ml = Regex.Match(xml, @"(-?\d+)"); + if (ml.Success) int.TryParse(ml.Groups[1].Value, out length); + if (length < 0) length = 0; + return (time, length); + } + + internal static string? ParseResolution(string xml) + { + var m = Regex.Match(xml, @"name=.Video_resolution.>\s*(\d+x\d+)", RegexOptions.IgnoreCase); + if (!m.Success) m = Regex.Match(xml, @"name=.Resolution.>\s*(\d+x\d+)", RegexOptions.IgnoreCase); + return m.Success ? m.Groups[1].Value : null; + } + + internal static (double rate, bool playing) ParseStatus(string xml) + { + double rate = 1.0; bool playing = true; + var mr = Regex.Match(xml, @"([\d.]+)"); + if (mr.Success) double.TryParse(mr.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out rate); + var ms = Regex.Match(xml, @"(\w+)"); + if (ms.Success) playing = ms.Groups[1].Value == "playing"; + return (rate, playing); + } + + internal static double NextPreset(double current, double[] presets) + { + foreach (var p in presets) if (p > current + 0.01) return p; + return presets[0]; + } + + public static void SetRate(double r) { Rate = r; Send($"?command=rate&val={r.ToString(CultureInfo.InvariantCulture)}"); } + public static void TogglePlay() { Playing = !Playing; Send("?command=pl_pause"); } + public static void Seek(int seconds) { Send($"?command=seek&val={(seconds >= 0 ? "+" : "")}{seconds}S"); } + + public static void SeekTo(float frac) + { + int len = Length; + if (len <= 0) return; + frac = Math.Clamp(frac, 0f, 1f); + int target = (int)(frac * len); + Time = target; + _seekTarget = target; + _seekSentAt = Environment.TickCount64; + Send($"?command=seek&val={(int)(frac * 100)}%"); + } + + public static void CycleSubtitle() { SubsOn = !SubsOn; Send("?command=key&val=subtitle-track"); } + + private static void Send(string query) + { + if (!_configured) return; + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { Get("/requests/status.xml" + query); Online = true; } catch { Online = false; } + }); + } + + private static string Get(string path) + => Http.GetStringAsync($"http://127.0.0.1:{Port}{path}").GetAwaiter().GetResult(); +} diff --git a/src/Halo.App/Widgets/VlcWidget.cs b/src/Halo.App/Widgets/VlcWidget.cs new file mode 100644 index 0000000..eef6f3d --- /dev/null +++ b/src/Halo.App/Widgets/VlcWidget.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Threading; +using System.Threading.Tasks; + +namespace Halo.Widgets; + +internal static class VlcMonitor +{ + public static volatile string? Name; + public static IntPtr Hwnd; + public static string? ExePath; + public static int Version; + + private const string Suffix = " - VLC media player"; + private static Timer? _timer; + private static readonly System.Text.StringBuilder Buf = new(512); + + public static void Poke() + { + if (_timer != null) return; + VlcHttp.EnsureConfigured(); + _timer = new Timer(_ => Scan(), null, 700, 1000); + } + + private static void Scan() + { + try + { + string? name = null; IntPtr hwnd = IntPtr.Zero; + Halo.Interop.Win32.EnumWindows((h, _) => + { + if (!Halo.Interop.Win32.IsWindowVisible(h)) return true; + int len = Halo.Interop.Win32.GetWindowTextLengthW(h); + if (len < Suffix.Length || len > 400) return true; + Buf.Clear(); + if (Halo.Interop.Win32.GetWindowTextW(h, Buf, Buf.Capacity) == 0) return true; + string t = Buf.ToString(); + if (!t.EndsWith(Suffix, StringComparison.OrdinalIgnoreCase)) return true; + try + { + Halo.Interop.Win32.GetWindowThreadProcessId(h, out uint pid); + using var p = System.Diagnostics.Process.GetProcessById((int)pid); + if (!p.ProcessName.Equals("vlc", StringComparison.OrdinalIgnoreCase)) return true; + if (Name != t) ExePath = p.MainModule?.FileName; + } + catch { return true; } + name = Fx.CleanText(t.Substring(0, t.Length - Suffix.Length)); + hwnd = h; + return false; + }, IntPtr.Zero); + + Hwnd = hwnd; + bool justClosed = name == null && Name != null; + if (name != Name) { Name = name; Interlocked.Increment(ref Version); } + if (name != null) VlcHttp.Poll(); + else if (justClosed) VlcHttp.EnsureConfigured(); + } + catch { } + } +} + +internal sealed class VlcWidget : IWidget +{ + private static readonly Color White = Color.FromArgb(238, 255, 255, 255); + private static readonly Color Dim = Color.FromArgb(150, 255, 255, 255); + private static readonly Color Orange = Color.FromArgb(255, 136, 0); + + private readonly MediaSessions _sessions; + private readonly float[] _hover = new float[NBtn]; + + private int _seenTime = -1; + private long _seenAt; + private bool _scrubbing, _wasDown; + private float _scrubFrac, _seekHover, _fracShown = -1f; + private long _dtTick; + + private float Dt() + { + long now = Environment.TickCount64; + float dt = _dtTick == 0 ? 1f / 60f : Math.Clamp((now - _dtTick) / 1000f, 0.001f, 0.1f); + _dtTick = now; + return dt; + } + + private static float Ease(float cur, float target, float dt, float tau) + => cur + (target - cur) * (1f - MathF.Exp(-dt / MathF.Max(0.0001f, tau))); + + private float Elapsed() + { + int seen = VlcHttp.Time; + if (seen < 0) { _seenTime = -1; return -1f; } + if (seen != _seenTime) { _seenTime = seen; _seenAt = Environment.TickCount64; } + float extra = VlcHttp.Online && VlcHttp.Playing ? (Environment.TickCount64 - _seenAt) / 1000f : 0f; + return seen + extra * (float)Math.Max(0.25, VlcHttp.Rate); + } + + private float Progress() + { + int len = VlcHttp.Length; + float el = Elapsed(); + if (len <= 0 || el < 0f) return -1f; + return Math.Clamp(el / len, 0f, 1f); + } + + private static string Fmt(TimeSpan t) => t.TotalHours >= 1 + ? ((int)t.TotalHours) + t.ToString(@"\:mm\:ss") : t.ToString(@"m\:ss"); + + private static RectangleF SeekRect(int w) { float tx = 26 + 132 + 22; return new RectangleF(tx, 108, w - tx - 26, 18); } + + public VlcWidget(MediaSessions sessions) + { + _sessions = sessions; + VlcMonitor.Poke(); + } + + private bool SmtcHasVlc() + { + for (int i = 0; i < MediaSessions.MaxSlots; i++) + if (_sessions.SlotApp(i).Contains("vlc")) return true; + return false; + } + + public string Icon => ""; + public Bitmap? IconImage => AppIcon.ForAumid(VlcMonitor.ExePath); + public bool IsActive => VlcMonitor.Name != null && !SmtcHasVlc(); + + public int Version => VlcMonitor.Version + (VlcHttp.Online + ? (int)(VlcHttp.Rate * 100) + (VlcHttp.Playing ? 1 : 0) + (VlcHttp.SubsOn ? 2 : 0) : 0); + public Color? Ring => IsActive ? Orange : null; + + public bool Animating => IsActive && VlcHttp.Online && VlcHttp.Playing; + + public float RingProgress => IsActive ? Progress() : -1f; + + private static readonly double[] SpeedPresets = { 1.0, 1.25, 1.5, 2.0 }; + + private static readonly (string label, int taps)[] Speeds = { ("1×", 0), ("1.2×", 2), ("1.45×", 4), ("1.95×", 7) }; + private int _speedIdx; + private const int NBtn = 5; + private static RectangleF[] BtnRects(int w, int h) + { + const float size = 40, gap = 14; + float colL = 26 + 132 + 22, cx = (colL + (w - 26)) / 2f, total = NBtn * size + (NBtn - 1) * gap, x0 = cx - total / 2f; + var r = new RectangleF[NBtn]; + for (int i = 0; i < NBtn; i++) r[i] = new RectangleF(x0 + i * (size + gap), 158, size, size); + return r; + } + + public IReadOnlyList<(RectangleF rect, Action onClick)> Buttons(int w, int h) + { + var r = BtnRects(w, h); + var seek = SeekRect(w); + var list = new List<(RectangleF, Action)>(); + + if (VlcHttp.Online && VlcHttp.Length > 0) + list.Add((seek, pt => VlcHttp.SeekTo((pt.X - seek.X) / Math.Max(1f, seek.Width)))); + list.AddRange(new (RectangleF, Action)[] + { + (r[0], _ => Seek(-10)), + (r[1], _ => Play()), + (r[2], _ => Seek(10)), + (r[3], _ => CycleSpeed()), + (r[4], _ => Subtitle()), + }); + return list; + } + + public void DrawContent(Graphics g, int w, int h, float fade) + { + if (fade <= 0.01f) return; + string? name = VlcMonitor.Name; + if (name == null) return; + var icon = IconImage; + + const float artX = 26, artY = 26, artSize = 132; + Fx.Glow(g, w, h, fade, artX + artSize / 2f, artY + artSize / 2f, w * 0.85f, h * 1.2f, 34, Orange); + using (var path = Fx.Rounded(new RectangleF(artX, artY, artSize, artSize), 14f)) + { + if (icon != null) + { + g.SetClip(path); + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + float inset = artSize * 0.14f; + g.DrawImage(icon, artX + inset, artY + inset, artSize - inset * 2, artSize - inset * 2); + g.ResetClip(); + } + else { using var b = new SolidBrush(Mul(Color.FromArgb(40, 255, 255, 255), fade)); g.FillPath(b, path); } + } + + float tx = artX + artSize + 22, tw = w - tx - 26; + using var titleF = new Font("Segoe UI Semibold", 21f, GraphicsUnit.Pixel); + using var bodyF = new Font("Segoe UI", 14f, GraphicsUnit.Pixel); + using (var tb = new SolidBrush(Mul(White, fade))) + using (var sf = new StringFormat(StringFormat.GenericTypographic) + { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap | (Fx.IsRtl(name) ? StringFormatFlags.DirectionRightToLeft : 0) }) + g.DrawString(name, titleF, tb, new RectangleF(tx, 40, tw, 30), sf); + + string? res = VlcHttp.Online ? VlcHttp.Resolution : null; + string info = MediaWidget.MetaLine(name, null, MediaFileInfo.Size(name, VlcMonitor.Poke) is { } sz + ? MediaFileInfo.Human(sz) : null, res); + using (var lb = new SolidBrush(Mul(Dim, fade))) + g.DrawString(info, bodyF, lb, tx, 76); + + float dt = Dt(); + var seek = SeekRect(w); + float frac = Progress(); + if (frac >= 0f) + { + if (_fracShown < 0f) _fracShown = frac; + var hit = seek; hit.Inflate(6f, 10f); + bool on = WidgetInput.Over && hit.Contains(WidgetInput.Mouse); + if (WidgetInput.Down && !_wasDown && on && VlcHttp.Online) _scrubbing = true; + if (_scrubbing) + { + _scrubFrac = Math.Clamp((WidgetInput.Mouse.X - seek.X) / Math.Max(1f, seek.Width), 0f, 1f); + if (!WidgetInput.Down) { VlcHttp.SeekTo(_scrubFrac); _scrubbing = false; _fracShown = _scrubFrac; } + } + _wasDown = WidgetInput.Down; + _seekHover = Ease(_seekHover, _scrubbing ? 1f : 0f, dt, 0.07f); + _fracShown = _scrubbing ? _scrubFrac : Ease(_fracShown, frac, dt, 0.10f); + + const float barCy = 118.5f, bhRest = 5f; + float bh = bhRest * (1f + 2f * _seekHover); + float by = barCy - bh / 2f; + using (var tb2 = new SolidBrush(Mul(Color.FromArgb(46, 255, 255, 255), fade))) + g.FillRectangle(tb2, tx, by, tw, bh); + if (_fracShown > 0f) + using (var fb2 = new SolidBrush(Mul(White, fade))) + g.FillRectangle(fb2, tx, by, tw * _fracShown, bh); + + int len = VlcHttp.Length; + using var timeF = new Font("Segoe UI", 12f, GraphicsUnit.Pixel); + using var eb = new SolidBrush(Mul(Dim, fade)); + float ty = barCy + bh / 2f + 3f; + var shown = TimeSpan.FromSeconds(_scrubbing ? _scrubFrac * len : Math.Max(0f, Elapsed())); + g.DrawString(Fmt(shown), timeF, eb, tx, ty); + var total = Fmt(TimeSpan.FromSeconds(len)); + var ts = g.MeasureString(total, timeF); + g.DrawString(total, timeF, eb, tx + tw - ts.Width, ty); + } + else _wasDown = WidgetInput.Down; + + var rects = BtnRects(w, h); + g.SmoothingMode = SmoothingMode.AntiAlias; + for (int i = 0; i < rects.Length; i++) + { + var r = rects[i]; + var hit = r; hit.Inflate(4f, 4f); + bool hov = WidgetInput.Over && hit.Contains(WidgetInput.Mouse); + _hover[i] += ((hov ? 1f : 0f) - _hover[i]) * 0.35f; + float t = _hover[i], sc = 1f + 0.09f * t, d = r.Width * sc; + var rr = new RectangleF(r.X + (r.Width - d) / 2f, r.Y + (r.Height - d) / 2f, d, d); + if (i != 4) + { + using (var fb = new SolidBrush(Mul(Color.FromArgb((int)(15 + 19 * t), 255, 255, 255), fade))) + g.FillEllipse(fb, rr); + using (var pen = new Pen(Mul(Color.FromArgb((int)(34 + 30 * t), 255, 255, 255), fade), 1f)) + g.DrawEllipse(pen, rr); + } + float a = fade * (0.8f + 0.2f * t); + switch (i) + { + case 0: Fx.DrawSeekArrow(g, rr, forward: false, a); break; + + case 1: + bool showPause = VlcHttp.Online && VlcHttp.Playing; + DrawGlyphPath(g, rr, ((char)(showPause ? 0xE769 : 0xE768)).ToString(), 22f, a, showPause ? 0f : 1.5f); + break; + case 2: Fx.DrawSeekArrow(g, rr, forward: true, a); break; + case 3: DrawSpeedLabel(g, rr, SpeedLabel(), a); break; + + case 4: + bool subsOff = VlcHttp.Online && !VlcHttp.SubsOn; + Fx.DrawCcMark(g, rr, a * (subsOff ? 0.32f : 1f)); + if (subsOff) DrawSubOffSlash(g, rr, a); + break; + } + } + } + + public void DrawCollapsed(Graphics g, int w, int h, float fade) + { + string? name = VlcMonitor.Name; + if (name == null) return; + float sz = h - 14f, x = 9, y = (h - sz) / 2f; + float prog = Progress(); + if (prog >= 0f) Fx.PillBar(g, w, h, fade, prog, Orange, 0.5f); + Fx.Glow(g, w, h, fade, x + sz / 2f, h / 2f, w * 0.7f, h * 2.2f, 30, Orange); + var icon = IconImage; + if (icon != null) + { + using var path = Fx.Rounded(new RectangleF(x, y, sz, sz), sz * 0.28f); + g.SetClip(path); + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.DrawImage(icon, x + 2, y + 2, sz - 4, sz - 4); + g.ResetClip(); + } + + using var f = new Font("Segoe UI Semibold", 14f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, fade)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap | (Fx.IsRtl(name) ? StringFormatFlags.DirectionRightToLeft : 0), LineAlignment = StringAlignment.Center }; + g.DrawString(name, f, b, new RectangleF(x + sz + 10, 0, w - (x + sz + 10) - 12, h), sf); + } + + private string SpeedLabel() => VlcHttp.Online + ? VlcHttp.Rate.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture) + "×" + : Speeds[_speedIdx].label; + + private static void Seek(int seconds) + { + if (VlcHttp.Online) VlcHttp.Seek(seconds); + else KeyInject.Send(VlcMonitor.Hwnd, (byte)(seconds < 0 ? 0x25 : 0x27), alt: true); + } + + private static void Play() + { + if (VlcHttp.Online) VlcHttp.TogglePlay(); + else KeyInject.Send(VlcMonitor.Hwnd, 0x20); + } + + private static void Subtitle() + { + if (VlcHttp.Online) VlcHttp.CycleSubtitle(); + else KeyInject.Send(VlcMonitor.Hwnd, (byte)'V'); + } + + private void CycleSpeed() + { + if (VlcHttp.Online) { VlcHttp.SetRate(VlcHttp.NextPreset(VlcHttp.Rate, SpeedPresets)); return; } + + _speedIdx = (_speedIdx + 1) % Speeds.Length; + var seq = new byte[1 + Speeds[_speedIdx].taps]; + seq[0] = 0xBB; + for (int i = 1; i < seq.Length; i++) seq[i] = 0xDD; + KeyInject.SendSeq(VlcMonitor.Hwnd, seq); + } + + private static void DrawSubOffSlash(Graphics g, RectangleF r, float a) + { + float pad = r.Width * 0.14f; + using var pen = new Pen(Mul(White, a * 0.9f), 2.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawLine(pen, r.Left + pad, r.Bottom - pad, r.Right - pad, r.Top + pad); + } + + private static void DrawSpeedLabel(Graphics g, RectangleF r, string label, float a) + { + using var f = new Font("Segoe UI Semibold", 13.5f, GraphicsUnit.Pixel); + using var b = new SolidBrush(Mul(White, a * 0.92f)); + using var sf = new StringFormat(StringFormat.GenericTypographic) + { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString(label, f, b, r, sf); + } + + private static readonly FontFamily Fluent = new("Segoe Fluent Icons"); + private static void DrawGlyphPath(Graphics g, RectangleF r, string glyph, float px, float fade, float dx = 0) + { + using var path = new GraphicsPath(); + using var sf = new StringFormat(StringFormat.GenericTypographic); + path.AddString(glyph, Fluent, (int)FontStyle.Regular, px, PointF.Empty, sf); + path.Flatten(); + var b = path.GetBounds(); + if (b.Width <= 0 || b.Height <= 0) return; + using var m = new Matrix(); + m.Translate(MathF.Round(r.X + (r.Width - b.Width) / 2f - b.X + dx), + MathF.Round(r.Y + (r.Height - b.Height) / 2f - b.Y)); + path.Transform(m); + using var br = new SolidBrush(Mul(White, fade * 0.92f)); + g.FillPath(br, path); + } + + private static Color Mul(Color c, float a) => Color.FromArgb((int)Math.Clamp(c.A * a, 0, 255), c.R, c.G, c.B); +} diff --git a/src/Halo.App/Widgets/WidgetInput.cs b/src/Halo.App/Widgets/WidgetInput.cs new file mode 100644 index 0000000..29216dc --- /dev/null +++ b/src/Halo.App/Widgets/WidgetInput.cs @@ -0,0 +1,11 @@ +using System.Drawing; + +namespace Halo.Widgets; + +internal static class WidgetInput +{ + public static PointF Mouse; + public static bool Over; + + public static bool Down; +} diff --git a/src/Halo.App/app.manifest b/src/Halo.App/app.manifest new file mode 100644 index 0000000..6b3fc96 --- /dev/null +++ b/src/Halo.App/app.manifest @@ -0,0 +1,8 @@ + + + + + PerMonitorV2 + + + diff --git a/src/Halo.Hooks/AskEnvelope.cs b/src/Halo.Hooks/AskEnvelope.cs new file mode 100644 index 0000000..18df1bd --- /dev/null +++ b/src/Halo.Hooks/AskEnvelope.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal sealed record AskOption(string Label, string Description); + +internal sealed record AskEnvelope( + string Nonce, + int Pid, + string? Session, + string Tool, + string? Target, + string? Question, + IReadOnlyList Options, + DateTimeOffset ExpiresAt) +{ + internal bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; + + internal bool IsQuestion => Tool == "AskUserQuestion"; + + internal string ToJson() + { + var options = new JsonArray(); + foreach (var o in Options) + options.Add(new JsonObject { ["label"] = o.Label, ["description"] = o.Description }); + return new JsonObject + { + ["nonce"] = Nonce, + ["pid"] = Pid, + ["session"] = Session, + ["tool"] = Tool, + ["target"] = Target, + ["question"] = Question, + ["options"] = options, + ["expiresAt"] = ExpiresAt.ToString("o"), + }.ToJsonString(); + } + + internal static AskEnvelope? FromJson(string? json) + { + try + { + if (JsonNode.Parse(json ?? "") is not JsonObject o) return null; + string? nonce = o["nonce"]?.GetValue(); + string? tool = o["tool"]?.GetValue(); + if (string.IsNullOrEmpty(nonce) || string.IsNullOrEmpty(tool)) return null; + if (!DateTimeOffset.TryParse(o["expiresAt"]?.GetValue(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var expires)) + return null; + + var options = new List(); + if (o["options"] is JsonArray arr) + foreach (var n in arr) + if (n is JsonObject oo && oo["label"]?.GetValue() is { Length: > 0 } label) + options.Add(new AskOption(label, oo["description"]?.GetValue() ?? "")); + + return new AskEnvelope( + nonce, + o["pid"] is JsonValue pv && pv.TryGetValue(out var pid) ? pid : 0, + o["session"]?.GetValue(), + tool, + o["target"]?.GetValue(), + o["question"]?.GetValue(), + options, + expires); + } + catch { return null; } + } +} + +internal sealed record AskAnswer(string Nonce, string Decision, string? Reason) +{ + internal string ToJson() => new JsonObject + { + ["nonce"] = Nonce, + ["decision"] = Decision, + ["reason"] = Reason, + }.ToJsonString(); + + internal static AskAnswer? FromJson(string? json) + { + try + { + if (JsonNode.Parse(json ?? "") is not JsonObject o) return null; + string? nonce = o["nonce"]?.GetValue(); + string? decision = o["decision"]?.GetValue(); + if (string.IsNullOrEmpty(nonce) || decision is not ("allow" or "deny" or "ask")) return null; + return new AskAnswer(nonce, decision, o["reason"]?.GetValue()); + } + catch { return null; } + } + + internal string ToHookStdout() => new JsonObject + { + ["hookSpecificOutput"] = new JsonObject + { + ["hookEventName"] = "PreToolUse", + ["permissionDecision"] = Decision, + ["permissionDecisionReason"] = Reason ?? "", + }, + }.ToJsonString(); +} diff --git a/src/Halo.Hooks/AskFlow.cs b/src/Halo.Hooks/AskFlow.cs new file mode 100644 index 0000000..9119df2 --- /dev/null +++ b/src/Halo.Hooks/AskFlow.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal static class AskFlow +{ + private const int AckMs = 300; + private const int AnswerMs = 20_000; + + private const int QuestionMs = 30 * 60_000; + private const int PollMs = 25; + + internal static void Run(string dir, JsonObject? input, string? sessionId, string? cwd, int pid) + { + try + { + string? tool = input?["tool_name"]?.GetValue(); + var toolInput = input?["tool_input"] as JsonObject; + if (!AskGate.ShouldAsk(tool, toolInput, AskSettings.AllowRules(cwd))) return; + + var ask = Envelope(tool!, toolInput!, sessionId, pid); + + if (ask.IsQuestion) { Publish(dir, ask, pid); return; } + + var answer = Wait(dir, ask); + if (answer is not null) Console.Out.Write(answer.ToHookStdout()); + } + catch { } + } + + private static void Publish(string dir, AskEnvelope ask, int pid) + { + try + { + Directory.CreateDirectory(dir); + Clear(dir, pid); + WriteAtomic(Path.Combine(dir, $"ask-{ask.Nonce}.json"), ask.ToJson()); + } + catch { } + } + + internal static void Clear(string dir, int pid) + { + try + { + if (pid <= 0 || !Directory.Exists(dir)) return; + foreach (var path in Directory.GetFiles(dir, "ask-*.json")) + { + try + { + if (System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path)) is not JsonObject o) continue; + if (o["pid"] is System.Text.Json.Nodes.JsonValue v && v.TryGetValue(out var p) && p == pid) + Delete(path); + } + catch { } + } + } + catch { } + } + + private static AskEnvelope Envelope(string tool, JsonObject toolInput, string? sessionId, int pid) + { + var options = new List(); + string? question = null; + + if (tool == "AskUserQuestion" && toolInput["questions"] is JsonArray qs && qs.Count == 1 + && qs[0] is JsonObject q) + { + question = q["question"]?.GetValue(); + if (q["options"] is JsonArray opts) + foreach (var n in opts) + if (n is JsonObject o && o["label"]?.GetValue() is { Length: > 0 } label) + options.Add(new AskOption(label, o["description"]?.GetValue() ?? "")); + } + else + { + + options.Add(new AskOption("allow", "run it")); + options.Add(new AskOption("deny", "skip it")); + } + + bool isQuestion = tool == "AskUserQuestion"; + return new AskEnvelope( + Guid.NewGuid().ToString("n"), pid, sessionId, tool, + AskGate.TargetOf(tool, toolInput), question, options, + DateTimeOffset.UtcNow.AddMilliseconds(isQuestion ? QuestionMs : AnswerMs)); + } + + private static AskAnswer? Wait(string dir, AskEnvelope ask) + { + string askPath = Path.Combine(dir, $"ask-{ask.Nonce}.json"); + string ackPath = Path.Combine(dir, $"ack-{ask.Nonce}"); + string answerPath = Path.Combine(dir, $"answer-{ask.Nonce}.json"); + try + { + Directory.CreateDirectory(dir); + WriteAtomic(askPath, ask.ToJson()); + + if (!WaitForFile(ackPath, AckMs)) return null; + if (!WaitForFile(answerPath, AnswerMs)) return null; + + var answer = AskAnswer.FromJson(ReadOrNull(answerPath)); + return answer?.Nonce == ask.Nonce ? answer : null; + } + catch { return null; } + finally + { + Delete(askPath); + Delete(ackPath); + Delete(answerPath); + } + } + + private static bool WaitForFile(string path, int timeoutMs) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (File.Exists(path)) return true; + System.Threading.Thread.Sleep(PollMs); + } + return File.Exists(path); + } + + private static void WriteAtomic(string path, string text) + { + string tmp = path + ".tmp"; + File.WriteAllText(tmp, text); + File.Move(tmp, path, overwrite: true); + } + + private static string? ReadOrNull(string path) + { + for (int i = 0; i < 5; i++) + { + try { return File.ReadAllText(path); } + catch (IOException) { System.Threading.Thread.Sleep(PollMs); } + } + return null; + } + + private static void Delete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } +} diff --git a/src/Halo.Hooks/AskGate.cs b/src/Halo.Hooks/AskGate.cs new file mode 100644 index 0000000..85ce3e3 --- /dev/null +++ b/src/Halo.Hooks/AskGate.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal static class AskGate +{ + internal static bool ShouldAsk(string? toolName, JsonObject? toolInput, IReadOnlyList allowRules) + { + if (string.IsNullOrEmpty(toolName) || toolInput is null) return false; + + if (toolName == "AskUserQuestion") + return toolInput["questions"] is JsonArray q && q.Count == 1; + + if (!AnswerPermissions) return false; + + string? target = TargetOf(toolName, toolInput); + foreach (var rule in allowRules) + if (AllowRuleMatches(rule, toolName, target)) return false; + return true; + } + + internal static bool AnswerPermissions; + + internal static string? TargetOf(string? toolName, JsonObject? toolInput) + { + if (toolInput is null) return null; + string? field = toolName switch + { + "Bash" or "PowerShell" => "command", + "Read" or "Write" or "Edit" or "NotebookEdit" => "file_path", + "WebFetch" => "url", + _ => null, + }; + if (field is null) return null; + return toolInput[field] is JsonValue v && v.TryGetValue(out var s) ? s : null; + } + + internal static bool AllowRuleMatches(string rule, string? toolName, string? target) + { + if (string.IsNullOrWhiteSpace(rule) || string.IsNullOrEmpty(toolName)) return false; + + int open = rule.IndexOf('('); + if (open < 0) return rule == toolName; + if (!rule.EndsWith(")", StringComparison.Ordinal)) return false; + + string tool = rule[..open], pattern = rule[(open + 1)..^1]; + if (tool.Length == 0 || pattern.Length == 0) return false; + if (tool != toolName) return false; + if (target is null) return false; + + if (pattern.EndsWith(":*", StringComparison.Ordinal)) + return target.StartsWith(pattern[..^2], StringComparison.Ordinal); + + return Glob(pattern, target); + } + + private static bool Glob(string pattern, string text) + { + int p = 0, t = 0, star = -1, mark = 0; + while (t < text.Length) + { + if (p < pattern.Length && (pattern[p] == '?' || pattern[p] == text[t])) { p++; t++; } + else if (p < pattern.Length && pattern[p] == '*') { star = p++; mark = t; } + else if (star >= 0) { p = star + 1; t = ++mark; } + else return false; + } + while (p < pattern.Length && pattern[p] == '*') p++; + return p == pattern.Length; + } +} diff --git a/src/Halo.Hooks/AskSettings.cs b/src/Halo.Hooks/AskSettings.cs new file mode 100644 index 0000000..521b84b --- /dev/null +++ b/src/Halo.Hooks/AskSettings.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal static class AskSettings +{ + private static readonly Dictionary Cache = new(); + + internal static IReadOnlyList AllowRules(string? cwd) + { + var rules = new List(); + foreach (var path in Sources(cwd)) + rules.AddRange(RulesFrom(path)); + return rules; + } + + private static IEnumerable Sources(string? cwd) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + yield return Path.Combine(home, ".claude", "settings.json"); + if (string.IsNullOrEmpty(cwd)) yield break; + yield return Path.Combine(cwd, ".claude", "settings.json"); + yield return Path.Combine(cwd, ".claude", "settings.local.json"); + } + + private static string[] RulesFrom(string path) + { + try + { + if (!File.Exists(path)) return []; + var stamp = File.GetLastWriteTimeUtc(path); + lock (Cache) + if (Cache.TryGetValue(path, out var hit) && hit.Stamp == stamp) + return hit.Rules; + + var parsed = Parse(File.ReadAllText(path)); + lock (Cache) Cache[path] = (stamp, parsed); + return parsed; + } + catch { return []; } + } + + private static string[] Parse(string json) + { + try + { + if (JsonNode.Parse(json) is not JsonObject o) return []; + if (o["permissions"]?["allow"] is not JsonArray allow) return []; + var rules = new List(); + foreach (var n in allow) + if (n?.GetValue() is { Length: > 0 } rule) rules.Add(rule); + return [.. rules]; + } + catch { return []; } + } +} diff --git a/src/Halo.Hooks/AssemblyInfo.cs b/src/Halo.Hooks/AssemblyInfo.cs new file mode 100644 index 0000000..48e24d8 --- /dev/null +++ b/src/Halo.Hooks/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Halo.Tests")] diff --git a/src/Halo.Hooks/Autostart.cs b/src/Halo.Hooks/Autostart.cs new file mode 100644 index 0000000..4f412a8 --- /dev/null +++ b/src/Halo.Hooks/Autostart.cs @@ -0,0 +1,129 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Halo.Hooks; + +internal static class Autostart +{ + internal const string TaskName = "Halo"; + + internal static void Install(string exePath) + { + if (string.IsNullOrWhiteSpace(exePath)) throw new ArgumentException("autostart needs an executable path."); + string xml = Path.Combine(Path.GetTempPath(), $"halo-autostart-{Guid.NewGuid():n}.xml"); + try + { + + File.WriteAllText(xml, Xml(exePath), new UnicodeEncoding(false, true)); + if (Run("/Create", "/TN", TaskName, "/XML", xml, "/F") != 0) + throw new InvalidOperationException("schtasks could not register the logon task."); + } + finally + { + try { File.Delete(xml); } catch { } + + RemoveLegacyShortcut(); + } + } + + internal static void Uninstall() + { + try { Run("/Delete", "/TN", TaskName, "/F"); } catch { } + RemoveLegacyShortcut(); + } + + internal static bool IsInstalled() + { + try { return Run("/Query", "/TN", TaskName) == 0; } + catch { return false; } + } + + private static void RemoveLegacyShortcut() + { + try + { + string startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup); + foreach (var name in new[] { "Halo.lnk", "DynamicWin.lnk" }) + { + string path = Path.Combine(startup, name); + if (File.Exists(path)) File.Delete(path); + } + } + catch { } + } + + private static int Run(params string[] args) + { + var psi = new ProcessStartInfo("schtasks.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi) ?? throw new InvalidOperationException("schtasks did not start."); + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); + p.WaitForExit(20_000); + return p.HasExited ? p.ExitCode : 1; + } + + private static string Xml(string exePath) + { + string user = Escape($"{Environment.UserDomainName}\\{Environment.UserName}"); + return $""" + + + + {user} + Starts Halo as soon as you sign in, ahead of the Startup folder queue. + \{TaskName} + + + + true + {user} + PT0S + + + + + {user} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + false + false + false + + false + false + + true + true + false + false + false + PT0S + 4 + + + + {Escape(exePath)} + + + +"""; + } + + private static string Escape(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); +} diff --git a/src/Halo.Hooks/CodexHookInstaller.cs b/src/Halo.Hooks/CodexHookInstaller.cs new file mode 100644 index 0000000..54b65d2 --- /dev/null +++ b/src/Halo.Hooks/CodexHookInstaller.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal static class CodexHookInstaller +{ + private static readonly (string Event, string Command, string? Matcher)[] ManagedHooks = + [ + ("SessionStart", "session-start", null), + ("UserPromptSubmit", "prompt", null), + ("PreToolUse", "tool", null), + ("PostToolUse", "tool-done", null), + ("PreCompact", "pre-compact", ".*"), + ("PostCompact", "post-compact", ".*"), + ("Stop", "stop", null), + ]; + + internal static void Install(string settingsPath, string hookExePath) + { + if (!Path.IsPathFullyQualified(hookExePath)) + throw new ArgumentException("The hook executable path must be absolute.", nameof(hookExePath)); + + var settings = Load(settingsPath); + var hooks = GetHooks(settings); + RemoveManagedHandlers(hooks); + + foreach (var managed in ManagedHooks) + { + var entries = GetEntries(hooks, managed.Event); + var handler = new JsonObject + { + ["type"] = "command", + ["command"] = $"\"{hookExePath}\" codex {managed.Command}", + }; + var entry = new JsonObject + { + ["hooks"] = new JsonArray(handler), + }; + if (managed.Matcher is not null) + entry["matcher"] = managed.Matcher; + entries.Add(entry); + } + + Save(settingsPath, settings); + } + + internal static void Uninstall(string settingsPath) + { + if (!File.Exists(settingsPath)) return; + + var settings = Load(settingsPath); + if (settings["hooks"] is JsonObject hooks) + RemoveManagedHandlers(hooks); + else if (settings["hooks"] is not null) + throw new JsonException("The Codex hooks property must be an object."); + + Save(settingsPath, settings, createBackup: false); + } + + private static JsonObject Load(string settingsPath) + { + if (!File.Exists(settingsPath)) return new JsonObject(); + return JsonNode.Parse(File.ReadAllText(settingsPath)) as JsonObject + ?? throw new JsonException("The Codex hook settings root must be an object."); + } + + private static JsonObject GetHooks(JsonObject settings) + { + if (settings["hooks"] is JsonObject hooks) return hooks; + if (settings["hooks"] is not null) + throw new JsonException("The Codex hooks property must be an object."); + + hooks = new JsonObject(); + settings["hooks"] = hooks; + return hooks; + } + + private static JsonArray GetEntries(JsonObject hooks, string eventName) + { + if (hooks[eventName] is JsonArray entries) return entries; + if (hooks[eventName] is not null) + throw new JsonException($"The Codex hook event '{eventName}' must be an array."); + + entries = new JsonArray(); + hooks[eventName] = entries; + return entries; + } + + private static void RemoveManagedHandlers(JsonObject hooks) + { + foreach (var managed in ManagedHooks) + { + if (hooks[managed.Event] is null) continue; + if (hooks[managed.Event] is not JsonArray entries) + throw new JsonException($"The Codex hook event '{managed.Event}' must be an array."); + + for (var entryIndex = entries.Count - 1; entryIndex >= 0; entryIndex--) + { + if (entries[entryIndex] is not JsonObject entry || + entry["hooks"] is not JsonArray handlers) + continue; + + for (var handlerIndex = handlers.Count - 1; handlerIndex >= 0; handlerIndex--) + { + if (handlers[handlerIndex] is JsonObject handler && + handler["command"] is JsonValue commandValue && + commandValue.TryGetValue(out var command) && + IsManagedCommand(command)) + handlers.RemoveAt(handlerIndex); + } + + if (handlers.Count == 0) + entries.RemoveAt(entryIndex); + } + } + } + + private static bool IsManagedCommand(string command) + { + var executableEnd = command.IndexOf("Halo.Hooks.exe", StringComparison.OrdinalIgnoreCase); + if (executableEnd < 0) return false; + + var tail = command[(executableEnd + "Halo.Hooks.exe".Length)..].TrimStart('"', ' ', '\t'); + if (tail.StartsWith("codex ", StringComparison.OrdinalIgnoreCase)) return true; + return ManagedHooks.Any(managed => + tail.Equals(managed.Command, StringComparison.OrdinalIgnoreCase)); + } + + private static void Save(string settingsPath, JsonObject settings, bool createBackup = true) + { + var directory = Path.GetDirectoryName(settingsPath); + if (string.IsNullOrEmpty(directory)) + throw new ArgumentException("The settings path must include a directory.", nameof(settingsPath)); + Directory.CreateDirectory(directory); + + if (createBackup && File.Exists(settingsPath)) + File.Copy(settingsPath, settingsPath + ".halo-bak", overwrite: true); + + var temporaryPath = settingsPath + ".tmp"; + try + { + File.WriteAllText(temporaryPath, settings.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + }), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + File.Move(temporaryPath, settingsPath, overwrite: true); + } + finally + { + try { File.Delete(temporaryPath); } catch { } + } + } +} diff --git a/src/Halo.Hooks/Halo.Hooks.csproj b/src/Halo.Hooks/Halo.Hooks.csproj new file mode 100644 index 0000000..6a538a0 --- /dev/null +++ b/src/Halo.Hooks/Halo.Hooks.csproj @@ -0,0 +1,11 @@ + + + Exe + net9.0-windows + enable + latest + Halo.Hooks + Halo.Hooks + true + + diff --git a/src/Halo.Hooks/Program.cs b/src/Halo.Hooks/Program.cs new file mode 100644 index 0000000..5a212df --- /dev/null +++ b/src/Halo.Hooks/Program.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Halo.Hooks; + +internal static class Program +{ + private static readonly string ClaudeDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "notch"); + private static readonly string ClaudeStatusPath = Path.Combine(ClaudeDir, "status.json"); + private static readonly string CodexDir = Environment.GetEnvironmentVariable("HALO_CODEX_STATUS_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "notch"); + + private static int Main(string[] args) + { + + if (args.Length > 0 && args[0] is "install-autostart" or "uninstall-autostart" or "query-autostart") + { + try + { + switch (args[0]) + { + case "install-autostart": + if (args.Length != 2) throw new ArgumentException("install-autostart requires an executable path."); + Autostart.Install(args[1]); + break; + case "uninstall-autostart": + Autostart.Uninstall(); + break; + default: + return Autostart.IsInstalled() ? 0 : 2; + } + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error.Message); + return 1; + } + } + + if (args.Length > 0 && args[0] is "install-codex-hooks" or "uninstall-codex-hooks") + { + try + { + var settingsPath = Environment.GetEnvironmentVariable("HALO_CODEX_HOOKS_PATH"); + if (string.IsNullOrWhiteSpace(settingsPath)) + settingsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "hooks.json"); + + if (args[0] == "install-codex-hooks") + { + if (args.Length != 2) + throw new ArgumentException("install-codex-hooks requires an executable path."); + CodexHookInstaller.Install(settingsPath, args[1]); + } + else + { + CodexHookInstaller.Uninstall(settingsPath); + } + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error.Message); + return 1; + } + } + + try + { + if (args.Length == 0) return 0; + var codex = args.Length >= 2 && args[0] == "codex"; + var cmd = codex ? args[1] : args[0]; + + if (cmd == "cancel") + { + if (args.Length >= 2 && int.TryParse(args[1], out var pid)) + Cancel(pid); + return 0; + } + + CodexSurface? surface = codex ? DetectCodexSurface() : null; + var dir = codex ? CodexDir : ClaudeDir; + + uint agentPid = 0; + var path = codex ? CodexStatusPath(surface!.Value) + : IsClaudeApp() ? Path.Combine(ClaudeDir, "app.json") : ClaudeSessionPath(out agentPid); + Directory.CreateDirectory(dir); + var input = ReadInput(); + var status = LoadOrNew(path); + + if (agentPid != 0) status["pid"] = (int)agentPid; + + if (cmd == "session-end" && !codex && path != ClaudeStatusPath) + { + try { File.Delete(path); } catch { } + try { File.Delete(ClaudeStatusPath); } catch { } + return 0; + } + + string? Field(string name) => input?[name]?.GetValue(); + + if (codex) + { + status["source"] = surface == CodexSurface.Desktop ? "desktop" : "cli"; + if (Field("cwd") is { } cwd) status["cwd"] = cwd; + } + + switch (cmd) + { + case "session-start": + if (!codex) SweepDeadSessions(); + status["sessionId"] = Field("session_id"); + status["cwd"] = Field("cwd"); + status["state"] = "idle"; + if (Field("source") == "compact") + status["compactedAt"] = DateTimeOffset.UtcNow.ToString("o"); + else if (Field("source") is "clear" or "startup") + status.Remove("session"); + RecordProcess(status, codex); + break; + case "pre-compact": + status["state"] = "compacting"; + status["startedAt"] = DateTimeOffset.UtcNow.ToString("o"); + status["message"] = null; + + UpdateContext(status, Field("transcript_path")); + break; + case "prompt": + status["state"] = "working"; + status["lastPrompt"] = Truncate(Field("prompt"), 120); + status["currentTool"] = null; + status["toolTarget"] = null; + status["startedAt"] = DateTimeOffset.UtcNow.ToString("o"); + status["message"] = null; + RecordProcess(status, codex); + UpdateContext(status, Field("transcript_path")); + break; + case "tool": + status["state"] = "working"; + status["currentTool"] = Field("tool_name"); + status["toolTarget"] = ToolTarget(input?["tool_name"]?.GetValue(), + AsObject(input?["tool_input"])); + break; + case "tool-done": + status["state"] = "working"; + + status["currentTool"] = null; + status["toolTarget"] = null; + UpdateContext(status, Field("transcript_path")); + break; + case "post-compact": + + status["state"] = codex || Field("trigger") == "auto" ? "working" : "idle"; + status["compactedAt"] = DateTimeOffset.UtcNow.ToString("o"); + if (!codex) + { + if (Field("trigger") != "auto") status["startedAt"] = null; + UpdateContext(status, Field("transcript_path")); + } + break; + case "notify": + + var prevState = status["state"]?.GetValue(); + if (prevState is "working" or "compacting") status["state"] = "waiting_input"; + status["message"] = Truncate(Field("message"), 160); + break; + case "stop": + status["state"] = "idle"; + status["currentTool"] = null; + status["toolTarget"] = null; + status["startedAt"] = null; + status["message"] = null; + UpdateContext(status, Field("transcript_path")); + break; + case "session-end": + status["state"] = "idle"; + status["currentTool"] = null; + status["toolTarget"] = null; + status["startedAt"] = null; + break; + default: + return 0; + } + + status["updatedAt"] = DateTimeOffset.UtcNow.ToString("o"); + Save(status, path); + + int askOwner = status["pid"] is JsonValue pv && pv.TryGetValue(out var askPid) ? askPid : 0; + if (cmd == "tool" && !codex) + AskFlow.Run(ClaudeDir, input, Field("session_id"), Field("cwd"), askOwner); + + bool questionOver = cmd is "prompt" or "stop" + || (cmd == "tool-done" && Field("tool_name") == "AskUserQuestion"); + if (questionOver && !codex) AskFlow.Clear(ClaudeDir, askOwner); + + return 0; + } + catch + { + return 0; + } + } + + private static JsonObject? ReadInput() + { + try + { + + using var stdin = Console.OpenStandardInput(); + using var reader = new System.IO.StreamReader(stdin, new System.Text.UTF8Encoding(false)); + var text = reader.ReadToEnd(); + if (string.IsNullOrWhiteSpace(text)) return null; + return JsonNode.Parse(text) as JsonObject; + } + catch + { + return null; + } + } + + private static string CodexStatusPath(CodexSurface surface) => + Path.Combine(CodexDir, surface == CodexSurface.Desktop ? "desktop.json" : "cli.json"); + + private static void SweepDeadSessions() + { + try + { + foreach (var f in Directory.GetFiles(ClaudeDir, "status-*.json")) + { + try + { + var pid = (JsonNode.Parse(File.ReadAllText(f)) as JsonObject)?["pid"]?.GetValue() ?? 0; + bool alive = false; + if (pid > 0) + try { using var p = System.Diagnostics.Process.GetProcessById(pid); alive = !p.HasExited; } + catch { } + if (!alive) File.Delete(f); + } + catch { } + } + } + catch { } + } + + private static string ClaudeSessionPath(out uint pid) + { + pid = Ancestor(ProcessMap(), (uint)Environment.ProcessId, + n => n.Contains("claude") || n == "node.exe"); + return pid == 0 ? ClaudeStatusPath : Path.Combine(ClaudeDir, $"status-{pid}.json"); + } + + private static JsonObject LoadOrNew(string path) + { + try + { + if (File.Exists(path)) + { + var text = File.ReadAllText(path); + if (JsonNode.Parse(text) is JsonObject o) return o; + } + } + catch + { + } + return new JsonObject(); + } + + private static void Save(JsonObject status, string path) + { + var tmp = path + ".tmp"; + File.WriteAllText(tmp, status.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + File.Move(tmp, path, overwrite: true); + } + + internal static JsonObject? AsObject(JsonNode? node) + { + if (node is JsonObject o) return o; + try + { + if (node is JsonValue v && v.TryGetValue(out var s) && !string.IsNullOrWhiteSpace(s)) + return JsonNode.Parse(s) as JsonObject; + } + catch { } + return null; + } + + internal static string? ToolTarget(string? tool, JsonObject? input) + { + if (tool is null || input is null) return null; + string? Str(string key) + { + try { return input[key]?.GetValue()?.Trim() is { Length: > 0 } v ? v : null; } + catch { return null; } + } + + var raw = tool switch + { + "Edit" or "Write" or "MultiEdit" or "NotebookEdit" or "Read" => Leaf(Str("file_path")), + "Bash" or "PowerShell" => Program_(Str("command")), + "Grep" or "Glob" => Str("pattern"), + "WebFetch" => Host(Str("url")), + "WebSearch" => Str("query"), + "Task" or "Agent" => Str("subagent_type"), + "Skill" or "SlashCommand" => Str("skill") ?? Str("command"), + _ => null, + }; + return Truncate(raw, 24); + } + + private static string? Leaf(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + var s = path.Replace('\\', '/').TrimEnd('/'); + var i = s.LastIndexOf('/'); + var leaf = i >= 0 ? s.Substring(i + 1) : s; + return leaf.Length > 0 ? leaf : null; + } + + private static string? Program_(string? command) + { + if (string.IsNullOrWhiteSpace(command)) return null; + var line = command.Trim(); + if (line.IndexOfAny(new[] { '|', ';', '&' }) >= 0) return null; + + if (line[0] is '"' or '\'') + { + var end = line.IndexOf(line[0], 1); + return end > 1 ? Clean(line.Substring(1, end - 1)) : null; + } + foreach (var word in line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + if (word.Contains('=')) continue; + if (Clean(word.Trim('"', '\'', '(')) is { } name) return name; + } + return null; + + static string? Clean(string word) + { + var leaf = Leaf(word); + if (string.IsNullOrEmpty(leaf)) return null; + if (leaf.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) leaf = leaf[..^4]; + return leaf.Length is > 0 and <= 14 ? leaf : null; + } + } + + private static string? Host(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + try { return new Uri(url).Host is { Length: > 0 } h ? h : null; } catch { return null; } + } + + private static string? Truncate(string? s, int max) + => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…"); + + private static void UpdateContext(JsonObject status, string? transcriptPath) + { + try + { + if (string.IsNullOrEmpty(transcriptPath) || !File.Exists(transcriptPath)) return; + var lines = File.ReadAllLines(transcriptPath); + + var started = DateTimeOffset.MinValue; + if (status["startedAt"] is JsonNode sn) + DateTimeOffset.TryParse(sn.GetValue(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out started); + + long latest = 0, turn = 0; + string? model = null; + for (int i = lines.Length - 1; i >= 0; i--) + { + if (string.IsNullOrWhiteSpace(lines[i])) continue; + JsonNode? node; + try { node = JsonNode.Parse(lines[i]); } catch { continue; } + var usage = node?["message"]?["usage"] ?? node?["usage"]; + if (usage == null) continue; + + long ctx = Get(usage, "input_tokens") + Get(usage, "cache_read_input_tokens") + + Get(usage, "cache_creation_input_tokens"); + if (latest == 0 && ctx > 0) + { + latest = ctx; + model = (node?["message"]?["model"] ?? node?["model"])?.GetValue(); + } + + if (started == DateTimeOffset.MinValue) { if (latest > 0) break; continue; } + var tsNode = node?["timestamp"]?.GetValue(); + if (!DateTimeOffset.TryParse(tsNode, null, + System.Globalization.DateTimeStyles.RoundtripKind, out var ts)) continue; + if (ts < started) { if (latest > 0) break; continue; } + turn += Get(usage, "input_tokens") + Get(usage, "cache_creation_input_tokens") + + Get(usage, "output_tokens"); + } + if (latest <= 0) return; + + var session = status["session"] as JsonObject ?? new JsonObject(); + session["contextUsed"] = latest; + session["contextMax"] = ContextWindow(model); + session["promptTokens"] = turn; + status["session"] = session; + } + catch + { + } + } + + private static long Get(JsonNode usage, string key) + { + try { return usage[key]?.GetValue() ?? 0; } + catch { return 0; } + } + + private static long ContextWindow(string? model) + { + var m = (model ?? "").ToLowerInvariant(); + if (m.Contains("haiku")) return 200_000; + if (m.Contains("opus") || m.Contains("fable") || m.Contains("sonnet")) return 1_000_000; + return 200_000; + } + + private static void RecordProcess(JsonObject status, bool codex = false) + { + var map = ProcessMap(); + uint start = (uint)Environment.ProcessId; + + uint agent = Ancestor(map, start, codex + ? n => n is "codex.exe" or "codex-code-mode-host.exe" or "chatgpt.exe" + : n => n.Contains("claude") || n == "node.exe"); + if (agent != 0) status["pid"] = (int)agent; + + uint term = Ancestor(map, start, IsTerminal); + if (term != 0) status["consolePid"] = (int)term; + } + + private enum CodexSurface { Cli, Desktop } + + private static bool IsClaudeApp() + { + var o = Environment.GetEnvironmentVariable("HALO_CLAUDE_SURFACE"); + if (!string.IsNullOrEmpty(o)) return o.Equals("app", StringComparison.OrdinalIgnoreCase); + return Ancestor(ProcessMap(), (uint)Environment.ProcessId, IsTerminal) == 0; + } + + private static CodexSurface DetectCodexSurface() + { + var overrideSurface = Environment.GetEnvironmentVariable("HALO_CODEX_SURFACE"); + if (string.Equals(overrideSurface, "desktop", StringComparison.OrdinalIgnoreCase)) + return CodexSurface.Desktop; + if (string.Equals(overrideSurface, "cli", StringComparison.OrdinalIgnoreCase)) + return CodexSurface.Cli; + + var map = ProcessMap(); + uint start = (uint)Environment.ProcessId; + if (Ancestor(map, start, n => n is "chatgpt.exe" or "codex-code-mode-host.exe") != 0) + return CodexSurface.Desktop; + if (Ancestor(map, start, IsTerminal) != 0) + return CodexSurface.Cli; + return CodexSurface.Cli; + } + + private static bool IsTerminal(string name) => name is + "windowsterminal.exe" or "wt.exe" or "conhost.exe" or "openconsole.exe" or + "powershell.exe" or "pwsh.exe" or "cmd.exe" or "bash.exe" or "wsl.exe" or + "alacritty.exe" or "wezterm-gui.exe" or "code.exe"; + + private static uint Ancestor(Dictionary map, uint start, Func match) + { + uint cur = start; + for (int i = 0; i < 16 && cur != 0 && map.TryGetValue(cur, out var e); i++) + { + if (match(e.name.ToLowerInvariant())) return cur == start ? e.parent : cur; + cur = e.parent; + } + return 0; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct PROCESSENTRY32 + { + public uint dwSize; + public uint cntUsage; + public uint th32ProcessID; + public IntPtr th32DefaultHeapID; + public uint th32ModuleID; + public uint cntThreads; + public uint th32ParentProcessID; + public int pcPriClassBase; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string szExeFile; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "Process32FirstW")] + private static extern bool Process32First(IntPtr snap, ref PROCESSENTRY32 pe); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "Process32NextW")] + private static extern bool Process32Next(IntPtr snap, ref PROCESSENTRY32 pe); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr h); + + private static Dictionary ProcessMap() + { + var map = new Dictionary(); + var snap = CreateToolhelp32Snapshot(0x2, 0); + if (snap == IntPtr.Zero || snap == new IntPtr(-1)) return map; + try + { + var pe = new PROCESSENTRY32 { dwSize = (uint)Marshal.SizeOf() }; + if (Process32First(snap, ref pe)) + do { map[pe.th32ProcessID] = (pe.th32ParentProcessID, pe.szExeFile); } + while (Process32Next(snap, ref pe)); + } + finally { CloseHandle(snap); } + return map; + } + + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AttachConsole(uint pid); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeConsole(); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateFile(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteConsoleInput(IntPtr h, INPUT_RECORD[] buffer, uint length, out uint written); + + [StructLayout(LayoutKind.Sequential)] + private struct KEY_EVENT_RECORD + { + public int bKeyDown; + public ushort wRepeatCount; + public ushort wVirtualKeyCode; + public ushort wVirtualScanCode; + public ushort UnicodeChar; + public uint dwControlKeyState; + } + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT_RECORD + { + public ushort EventType; + public ushort _pad; + public KEY_EVENT_RECORD Key; + } + + private static void Cancel(int pid) + { + FreeConsole(); + if (!AttachConsole((uint)pid)) return; + try + { + const uint GENERIC_RW = 0x80000000 | 0x40000000, SHARE_RW = 1 | 2, OPEN_EXISTING = 3; + IntPtr hIn = CreateFile("CONIN$", GENERIC_RW, SHARE_RW, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); + if (hIn == IntPtr.Zero || hIn == new IntPtr(-1)) return; + var recs = new[] + { + new INPUT_RECORD { EventType = 1, Key = new KEY_EVENT_RECORD { bKeyDown = 1, wRepeatCount = 1, wVirtualKeyCode = 0x1B, wVirtualScanCode = 0x01, UnicodeChar = 0x1B } }, + new INPUT_RECORD { EventType = 1, Key = new KEY_EVENT_RECORD { bKeyDown = 0, wRepeatCount = 1, wVirtualKeyCode = 0x1B, wVirtualScanCode = 0x01, UnicodeChar = 0x1B } }, + }; + WriteConsoleInput(hIn, recs, (uint)recs.Length, out _); + CloseHandle(hIn); + } + finally { FreeConsole(); } + } +} diff --git a/src/Halo.Settings/Actions.cs b/src/Halo.Settings/Actions.cs new file mode 100644 index 0000000..72fa77b --- /dev/null +++ b/src/Halo.Settings/Actions.cs @@ -0,0 +1,51 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace Halo.Settings; + +internal static class Actions +{ + private static string HaloDir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Halo"); + + internal static void Run(string key) + { + try + { + switch (key) + { + + case "general.reset": + Directory.CreateDirectory(HaloDir); + File.WriteAllText(Path.Combine(HaloDir, "offset"), "0"); + break; + case "access.notifications": + Open("ms-settings:privacy-notifications"); + break; + case "access.startup": + Open(Environment.GetFolderPath(Environment.SpecialFolder.Startup)); + break; + + case "about.state": + Directory.CreateDirectory(HaloDir); + Open(HaloDir); + break; + + case "api.token": + var token = new Store().Text("api.token", ""); + if (token.Length > 0) System.Windows.Clipboard.SetText(token); + break; + case "about.repo": + Open("https://github.com/phoseinq/DynamicWin"); + break; + } + } + catch { } + } + + private static void Open(string target) + { + try { Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); } catch { } + } +} diff --git a/src/Halo.Settings/App.xaml b/src/Halo.Settings/App.xaml new file mode 100644 index 0000000..b599425 --- /dev/null +++ b/src/Halo.Settings/App.xaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Halo.Settings/App.xaml.cs b/src/Halo.Settings/App.xaml.cs new file mode 100644 index 0000000..6b3a424 --- /dev/null +++ b/src/Halo.Settings/App.xaml.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows; + +namespace Halo.Settings; + +public partial class App : Application +{ + private static Mutex? _instance; + + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hwnd, int cmd); + + private const int Restore = 9; + + protected override void OnStartup(StartupEventArgs e) + { + if (e.Args.Length >= 2 && e.Args[0] == "--render-page") + { + Preview.Render(e.Args[1], e.Args.Length >= 3 ? e.Args[2] : "home", + e.Args.Length >= 4 ? e.Args[3] : ""); + Shutdown(); + return; + } + + _instance = new Mutex(true, "Halo.Settings.SingleInstance", out bool created); + if (!created) + { + Surface(); + Shutdown(); + return; + } + base.OnStartup(e); + } + + private static void Surface() + { + try + { + int self = Environment.ProcessId; + foreach (var process in Process.GetProcessesByName("Halo.Settings")) + { + using (process) + { + if (process.Id == self || process.MainWindowHandle == IntPtr.Zero) continue; + ShowWindow(process.MainWindowHandle, Restore); + SetForegroundWindow(process.MainWindowHandle); + return; + } + } + } + catch { } + } +} diff --git a/src/Halo.Settings/Catalog.cs b/src/Halo.Settings/Catalog.cs new file mode 100644 index 0000000..293b891 --- /dev/null +++ b/src/Halo.Settings/Catalog.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; + +namespace Halo.Settings; + +internal enum PageId { Home, General, Features, Agents, Api, Access, DocsAbout } + +internal enum RowKind { Toggle, Choice, Slider, Action, Status } + +internal sealed record Row( + string Key, + string Label, + string Description, + RowKind Kind, + string Fallback, + IReadOnlyList Options, + string ActionLabel = ""); + +internal sealed record Section(string Label, string Glyph, IReadOnlyList Rows); + +internal sealed record Page(PageId Id, string Label, string Description, IReadOnlyList
Sections); + +internal sealed record NavGroup(string Header, IReadOnlyList Pages); + +internal static class Catalog +{ + private static Row Toggle(string key, string label, string description, bool on = true) + => new(key, label, description, RowKind.Toggle, on ? "on" : "off", ["off", "on"]); + + private static Row Choice(string key, string label, string description, string fallback, params string[] options) + => new(key, label, description, RowKind.Choice, fallback, options); + + private static Row Slider(string key, string label, string description, string fallback, params string[] stops) + => new(key, label, description, RowKind.Slider, fallback, stops); + + private static Row Action(string key, string label, string description, string action) + => new(key, label, description, RowKind.Action, "", [], action); + + internal static readonly NavGroup[] Nav = + [ + new("", [PageId.Home]), + new("SETTINGS", [PageId.General, PageId.Features, PageId.Agents]), + new("SYSTEM", [PageId.Api, PageId.Access]), + new("REFERENCE", [PageId.DocsAbout]), + ]; + + internal static readonly Page[] Pages = + [ + + new(PageId.Home, "Home", "A quieter place to begin", []), + new(PageId.General, "General", "Core behaviour and appearance for the Halo surface", + [ + new("APPEARANCE", "\uE790", [ + Slider("appearance.scale", "Pill scale", "Scale geometry, type and hit targets together", + "100%", "90%", "95%", "100%", "105%", "110%"), + Choice("appearance.glass", "Glass strength", "Balance wallpaper detail against contrast", + "Balanced", "Light", "Balanced", "Strong"), + Choice("appearance.motion", "Motion", "How quickly the pill settles after it moves", + "Soft", "Reduced", "Soft", "Standard"), + ]), + new("STARTUP", "\uE7E8", [ + Toggle("general.startup", "Start with Windows", "Launch Halo after you sign in"), + Toggle("general.fullscreen", "Stay visible over fullscreen", "Keep the pill above games and video", false), + ]), + new("BEHAVIOUR", "\uE945", [ + Toggle("general.capture", "Include Halo in captures", "Show the pill in screenshots and recordings", false), + Toggle("general.follow", "Follow focused apps", "Bring the relevant surface forward automatically"), + Action("general.reset", "Pill position", "Return the pill to the active display centre", "Reset position"), + ]), + ]), + new(PageId.Features, "Features", "What the pill is allowed to show, and when", + [ + new("SURFACES", "\uE7F4", [ + Toggle("feature.media", "Media", "Playback sessions and classic VLC controls"), + Toggle("media.progress", "Show the timeline", "Draw the real playback position across the collapsed pill"), + Toggle("feature.downloads", "Downloads", "Browser, store, game and app progress"), + Toggle("feature.fileTray", "File Tray", "The drag-and-drop shelf and clipboard images"), + Toggle("feature.bluetooth", "Bluetooth", "Connection and battery takeovers"), + ]), + new("NOTIFICATIONS", "\uE8BD", [ + Toggle("feature.notifications", "Mirror notifications", "Show Windows toasts in the pill"), + Toggle("notifications.silence", "Silence the native banner", + "Stop Windows drawing its own banner for apps Halo mirrors. Fully reversible.", false), + ]), + new("ALERTS ABOUT THIS MACHINE", "\uE9D9", [ + Toggle("alert.battery", "Battery", "With a tap to turn on Power Saver. 10% is always critical."), + Slider("alert.batteryAt", "Warn me at", "Where the first battery warning fires", + "20%", "10%", "15%", "20%", "25%", "30%", "40%"), + Toggle("alert.cpu", "High CPU", "Once per tier, naming the process using the most"), + Slider("alert.cpuAt", "Start warning at", "Higher tiers above this one still escalate", + "50%", "40%", "50%", "60%", "70%", "80%", "90%"), + Toggle("alert.memory", "High memory", "Once per tier, naming the process using the most"), + Slider("alert.memoryAt", "Start warning at", "Higher tiers above this one still escalate", + "70%", "50%", "60%", "70%", "80%", "90%"), + Toggle("alert.internet", "Internet", "Slow, offline, and the API being unreachable"), + Toggle("alert.clipboard", "Screenshots and copies", "A banner when something lands on the clipboard"), + Toggle("alert.language", "Keyboard layout", "A one-second glance when the layout flips"), + Toggle("alert.hourly", "Hourly chime", "On the hour, with the date and the sky", false), + ]), + ]), + new(PageId.Agents, "Agents", "Claude Code, Codex, and anything else that reports in", + [ + new("SESSIONS", "\uE716", [ + Toggle("feature.claudeCode", "Claude Code", "Live sessions, limits and the cancel button"), + Toggle("feature.codex", "Codex", "Codex Desktop and CLI sessions"), + Toggle("feature.genericAgents", "Other agents", "Any tool writing ~/.halo/agents"), + ]), + new("QUESTIONS", "\uE9CE", [ + Toggle("claude.ask", "Answer from the pill", + "Mirror Claude's question box and answer it by clicking a row"), + ]), + new("ALERTS", "\uEA80", [ + Toggle("alert.context", "Context nearly full", "Once per session"), + Slider("alert.contextAt", "Context warning at", "Also where the agent ring turns amber", + "80%", "60%", "70%", "75%", "80%", "85%", "90%"), + Toggle("alert.limit", "Usage limits", "Once per window"), + Slider("alert.limitAt", "Usage warning at", "Share of a five-hour or weekly window", + "80%", "60%", "70%", "80%", "90%", "95%"), + ]), + ]), + new(PageId.Api, "API", "Let other programs drive the pill", + [ + new("ENDPOINT", "\uE968", [ + Toggle("api.enabled", "Local API", "Listen on 127.0.0.1 for local programs. Nothing off this machine can reach it.", false), + Choice("api.port", "Port", "Change it only if something else already has this one", + "7317", "7317", "7318", "8317", "9317"), + new("api.token", "Token", "Generated when you first switch the API on. Send it as an Authorization: Bearer header.", + RowKind.Status, "", [], "Copy"), + ]), + new("WHAT CALLERS MAY DO", "\uE8D7", [ + Toggle("api.notify", "Post a notification", "POST /notify - title, body, and an optional code or file to open"), + Toggle("api.ask", "Ask a question", "POST /ask with options, then poll /ask/{nonce} for the answer"), + Toggle("api.state", "Read what is on screen", "GET /state, /media, /agents, /tray", false), + Toggle("api.control", "Press buttons", "POST /media, /pill and /tray: play, skip, expand, pin, add files", false), + Toggle("api.settings", "Read and change settings", "GET and PATCH /settings. This is every switch in this window.", false), + ]), + ]), + new(PageId.Access, "Access", "What Halo needs from Windows to do its job", + [ + new("PERMISSIONS", "\uE890", [ + new("access.notifications", "Notification access", "Required to mirror Windows toasts", + RowKind.Status, "", [], "Open settings"), + new("access.startup", "Startup entry", "The shortcut that launches Halo when you sign in", + RowKind.Status, "", [], "Open folder"), + ]), + ]), + new(PageId.DocsAbout, "Docs & About", "Where things are written down", + [ + new("HALO", "\uE946", [ + new("about.version", "Version", "", RowKind.Status, "", []), + new("about.state", "State folder", "Loose files Halo keeps: position, pin, tray, seen notifications", + RowKind.Status, "", [], "Open folder"), + ]), + new("PROJECT", "\uE943", [ + new("about.repo", "Repository", "github.com/phoseinq/DynamicWin", RowKind.Status, "", [], "Open"), + ]), + ]), + ]; + + internal static Page Get(PageId id) => System.Array.Find(Pages, p => p.Id == id)!; + + internal static readonly PageId[] HomeShortcuts = + [PageId.General, PageId.Features, PageId.Agents, PageId.Access]; + + internal static string Sub(PageId page) => page switch + { + PageId.General => "Behaviour and appearance", + PageId.Features => "App surfaces", + PageId.Agents => "Coding sessions", + PageId.Api => "Drive Halo from code", + _ => "Windows controls", + }; + + internal static string Glyph(PageId page) => page switch + { + PageId.Home => "\uE80F", + PageId.General => "\uE713", + PageId.Features => "\uE71D", + PageId.Agents => "\uE716", + PageId.Api => "\uE968", + PageId.Access => "\uE8D7", + _ => "\uE943", + }; + + internal static (byte R, byte G, byte B) Accent(PageId page) => page switch + { + PageId.Home => (0x74, 0xE6, 0xC2), + PageId.General => (0x7C, 0xB4, 0xFF), + PageId.Features => (0xFF, 0x91, 0xC8), + PageId.Agents => (0xD7, 0x9B, 0xFF), + PageId.Api => (0x8B, 0xE0, 0xC8), + PageId.Access => (0xF0, 0xAE, 0x72), + _ => (0x5F, 0xDF, 0xE5), + }; +} diff --git a/src/Halo.Settings/Glass.cs b/src/Halo.Settings/Glass.cs new file mode 100644 index 0000000..c212a63 --- /dev/null +++ b/src/Halo.Settings/Glass.cs @@ -0,0 +1,55 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace Halo.Settings; + +internal static class Glass +{ + private const int Backdrop = 38; + private const int DarkMode = 20; + private const int CornerStyle = 33; + + private const int Acrylic = 3; + private const int Mica = 2; + private const int RoundCorners = 2; + + [StructLayout(LayoutKind.Sequential)] + private struct MARGINS { public int Left, Right, Top, Bottom; } + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); + + internal static void Apply(Window window) + { + try + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero) return; + + int dark = 1; + DwmSetWindowAttribute(handle, DarkMode, ref dark, sizeof(int)); + + int corner = RoundCorners; + DwmSetWindowAttribute(handle, CornerStyle, ref corner, sizeof(int)); + + var source = HwndSource.FromHwnd(handle); + if (source is not null) source.CompositionTarget.BackgroundColor = System.Windows.Media.Colors.Transparent; + + var margins = new MARGINS { Left = -1, Right = -1, Top = -1, Bottom = -1 }; + DwmExtendFrameIntoClientArea(handle, ref margins); + + int backdrop = Acrylic; + if (DwmSetWindowAttribute(handle, Backdrop, ref backdrop, sizeof(int)) != 0) + { + backdrop = Mica; + DwmSetWindowAttribute(handle, Backdrop, ref backdrop, sizeof(int)); + } + } + catch { } + } +} diff --git a/src/Halo.Settings/Halo.Settings.csproj b/src/Halo.Settings/Halo.Settings.csproj new file mode 100644 index 0000000..d84a973 --- /dev/null +++ b/src/Halo.Settings/Halo.Settings.csproj @@ -0,0 +1,23 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + true + enable + enable + latest + Halo.Settings + Halo.Settings + ..\Halo.App\Assets\halo.ico + + true + + + + + + + + diff --git a/src/Halo.Settings/Live.cs b/src/Halo.Settings/Live.cs new file mode 100644 index 0000000..af208a6 --- /dev/null +++ b/src/Halo.Settings/Live.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; + +namespace Halo.Settings; + +internal static class Live +{ + internal enum State { Neutral, Enabled, Attention } + + internal static string Value(Row row) => row.Key switch + { + + "api.token" => Token, + "about.version" => Version, + "access.startup" => StartupShortcut ? "On" : "Missing", + "access.notifications" => "Managed by Windows", + _ => row.Fallback, + }; + + internal static State Tone(string value) => value.ToLowerInvariant() switch + { + "on" or "allowed" or "watching" => State.Enabled, + "off" or "missing" or "denied" or "needs access" => State.Attention, + _ => State.Neutral, + }; + + private static string Token + { + get + { + try + { + var store = new Store(); + string token = store.Text("api.token", ""); + return token.Length >= 8 ? token[..4] + "..." + token[^4..] : "Not generated yet"; + } + catch { return "Not generated yet"; } + } + } + + private static string Version + { + get + { + try { return typeof(Live).Assembly.GetName().Version?.ToString(3) ?? "unknown"; } + catch { return "unknown"; } + } + } + + private static bool StartupShortcut + { + get + { + try + { + string dir = Environment.GetFolderPath(Environment.SpecialFolder.Startup); + foreach (var link in Directory.EnumerateFiles(dir, "*.lnk")) + { + string name = Path.GetFileNameWithoutExtension(link); + if (name.Contains("Halo", StringComparison.OrdinalIgnoreCase) + || name.Contains("DynamicWin", StringComparison.OrdinalIgnoreCase)) return true; + } + } + catch { } + return false; + } + } +} diff --git a/src/Halo.Settings/MainWindow.xaml b/src/Halo.Settings/MainWindow.xaml new file mode 100644 index 0000000..7d7bbe6 --- /dev/null +++ b/src/Halo.Settings/MainWindow.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +