├── .eslintignore ├── .eslintrc.yaml ├── .git-blame-ignore-revs ├── .gitignore ├── .prettierrc.yaml ├── .yarn └── releases │ └── yarn-4.3.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── agent ├── .containerignore ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Containerfile ├── build └── src │ ├── luna.rs │ ├── main.rs │ ├── rewire.rs │ ├── routines.rs │ └── routines │ ├── elevator.rs │ ├── keyfilter.rs │ ├── memory_manager.rs │ ├── root_syml.rs │ └── shadow.js ├── manifests ├── app │ ├── appinfo.json │ ├── icon.svg │ ├── icon130.png │ ├── icon320.png │ └── icon80.png └── service │ ├── package.json │ └── services.json ├── package.json ├── service ├── bus │ ├── index.ts │ ├── message.ts │ ├── palmbus.d.ts │ └── sink.ts ├── environment.ts ├── global.d.ts ├── index.ts ├── routine.ts ├── routines │ ├── index.ts │ └── root-syml.routine.ts └── tsconfig.json ├── src ├── app │ ├── app.init.ts │ ├── app.tsx │ ├── index.html │ ├── index.ts │ └── styles │ │ └── global.scss ├── assets │ ├── assets.d.ts │ ├── hide.png │ ├── plus.png │ ├── remove.png │ └── swap.png ├── chore │ └── webpack-utils │ │ ├── definitions.ts │ │ ├── index.ts │ │ ├── json-transformer.ts │ │ ├── package.json │ │ └── tsconfig.json ├── features │ └── ribbon │ │ ├── index.ts │ │ ├── lib │ │ ├── index.ts │ │ └── menu-action.lib.ts │ │ ├── ribbon.init.ts │ │ ├── services │ │ ├── app-drawer │ │ │ ├── app-drawer.module.ts │ │ │ ├── app-drawer.service.ts │ │ │ └── index.ts │ │ ├── context-menu │ │ │ ├── context-menu.module.ts │ │ │ ├── context-menu.service.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── keyboard │ │ │ ├── index.ts │ │ │ ├── keyboard.interface.ts │ │ │ ├── keyboard.lib.ts │ │ │ ├── keyboard.module.ts │ │ │ ├── keyboard.service.ts │ │ │ └── timer-ref │ │ │ │ ├── index.ts │ │ │ │ └── timer-ref.store.ts │ │ ├── ribbon │ │ │ ├── index.ts │ │ │ ├── ribbon.module.ts │ │ │ └── ribbon.service.ts │ │ └── scroll │ │ │ ├── index.ts │ │ │ ├── scroll.module.ts │ │ │ └── scroll.service.ts │ │ └── ui │ │ ├── ribbon-app-drawer │ │ ├── index.ts │ │ ├── ribbon-app-drawer-item │ │ │ ├── index.ts │ │ │ ├── ribbon-app-drawer-item.component.tsx │ │ │ ├── ribbon-app-drawer-item.interface.ts │ │ │ └── ribbon-app-drawer-item.module.scss │ │ ├── ribbon-app-drawer-list │ │ │ ├── index.ts │ │ │ ├── ribbon-app-drawer-list.component.tsx │ │ │ └── ribbon-app-drawer-list.module.scss │ │ ├── ribbon-app-drawer.component.tsx │ │ └── ribbon-app-drawer.module.scss │ │ ├── ribbon-card │ │ ├── index.ts │ │ ├── ribbon-card.component.tsx │ │ ├── ribbon-card.interface.ts │ │ └── ribbon-card.module.scss │ │ ├── ribbon-context-menu │ │ ├── index.ts │ │ ├── ribbon-context-menu-action │ │ │ ├── index.ts │ │ │ ├── ribbon-context-menu-action.component.tsx │ │ │ ├── ribbon-context-menu-action.interface.ts │ │ │ ├── ribbon-context-menu-action.lib.ts │ │ │ └── ribbon-context-menu-action.module.scss │ │ ├── ribbon-context-menu.component.tsx │ │ ├── ribbon-context-menu.interface.ts │ │ └── ribbon-context-menu.module.scss │ │ ├── ribbon-scroll-trigger │ │ ├── index.ts │ │ ├── ribbon-scroll-trigger.component.tsx │ │ ├── ribbon-scroll-trigger.interface.ts │ │ └── ribbon-scroll-trigger.module.scss │ │ └── ribbon │ │ ├── index.ts │ │ ├── ribbon.component.tsx │ │ └── ribbon.module.scss ├── index.tsx └── shared │ ├── api │ ├── global.d.ts │ └── webos.d.ts │ ├── core │ ├── di │ │ ├── container.context.ts │ │ ├── container.provider.tsx │ │ ├── di.container.ts │ │ └── index.ts │ └── utils │ │ └── throttle.ts │ └── services │ ├── launcher │ ├── api │ │ └── launch-point.interface.ts │ ├── index.ts │ ├── launcher.module.ts │ ├── launcher.tokens.ts │ ├── model │ │ ├── launch-point.model.ts │ │ └── launcher.service.ts │ └── providers │ │ ├── app-manager │ │ ├── app-manager.interface.ts │ │ └── app-manager.provider.ts │ │ ├── index.ts │ │ ├── input-manager │ │ ├── input-manager.interface.ts │ │ └── input-manager.provider.ts │ │ ├── internal │ │ └── internal.provider.ts │ │ └── launch-points.provider.ts │ ├── lifecycle-manager │ ├── api │ │ ├── compositor.interface.ts │ │ └── lifecycle-manager.interface.ts │ ├── index.ts │ ├── lifecycle-manager.module.ts │ └── service │ │ └── lifecycle-manager.service.ts │ ├── luna │ ├── api │ │ └── luna.api.ts │ ├── index.ts │ ├── lib │ │ └── auto-elevator.lib.ts │ └── model │ │ └── luna.service.ts │ ├── services.init.ts │ ├── settings │ ├── index.ts │ ├── model │ │ └── settings.service.ts │ └── settings.module.ts │ └── system-info │ ├── api │ └── system-info.interface.ts │ ├── index.ts │ ├── lib │ └── system-info-keys.lib.ts │ ├── model │ └── system-info.service.ts │ └── system-info.module.ts ├── tsconfig.json ├── webpack.app.ts ├── webpack.config.ts ├── webpack.service.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | node_modules 3 | dist 4 | agent 5 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | parser: '@typescript-eslint/parser' 2 | parserOptions: 3 | project: ./tsconfig.json 4 | ecmaVersion: 2020 5 | ecmaFeatures: 6 | jsx: true 7 | 8 | settings: 9 | react: 10 | version: detect 11 | import/resolver: 12 | typescript: true 13 | 14 | root: true 15 | 16 | env: 17 | node: true 18 | 19 | extends: 20 | - airbnb-base 21 | - airbnb-typescript/base 22 | - prettier 23 | - 'plugin:sonarjs/recommended' 24 | - 'plugin:react/jsx-runtime' 25 | - 'plugin:react-hooks/recommended' 26 | - 'plugin:import/recommended' 27 | - 'plugin:import/typescript' 28 | 29 | plugins: 30 | - prettier 31 | 32 | rules: 33 | prettier/prettier: warn 34 | 35 | max-len: 36 | - error 37 | - code: 120 38 | 39 | curly: 40 | - error 41 | - all 42 | 43 | no-void: 44 | - error 45 | - allowAsStatement: true 46 | 47 | no-underscore-dangle: 48 | - error 49 | - allow: [ __DEV__ ] 50 | 51 | no-restricted-globals: 52 | - error 53 | - React 54 | 55 | no-restricted-imports: 56 | - error 57 | - patterns: [ '../../../*' ] 58 | 59 | no-restricted-exports: 60 | - error 61 | - restrictDefaultExports: 62 | direct: true 63 | 64 | no-unused-vars: 65 | - error 66 | - argsIgnorePattern: '^_' 67 | ignoreRestSiblings: true 68 | 69 | no-param-reassign: 70 | - error 71 | - ignorePropertyModificationsFor: [ ref ] 72 | 73 | no-console: off 74 | no-plusplus: off 75 | no-nested-ternary: off 76 | no-restricted-syntax: off 77 | no-promise-executor-return: off 78 | 79 | default-case: off 80 | 81 | lines-between-class-members: 82 | - error 83 | - always 84 | - exceptAfterSingleLine: true 85 | 86 | react/jsx-newline: error 87 | react/no-array-index-key: error 88 | react/jsx-curly-brace-presence: 89 | - error 90 | - never 91 | 92 | '@typescript-eslint/no-floating-promises': 93 | - error 94 | - ignoreVoid: true 95 | 96 | '@typescript-eslint/no-unused-vars': off 97 | '@typescript-eslint/lines-between-class-members': off 98 | 99 | import/prefer-default-export: off 100 | import/no-extraneous-dependencies: off 101 | 102 | import/consistent-type-specifier-style: 103 | - error 104 | - prefer-top-level 105 | '@typescript-eslint/consistent-type-imports': 106 | - error 107 | - prefer: type-imports 108 | fixStyle: inline-type-imports 109 | 110 | import/order: 111 | - error 112 | - alphabetize: 113 | order: asc 114 | newlines-between: always 115 | groups: [ builtin, external, internal, parent, sibling ] 116 | pathGroupsExcludedImportTypes: [ builtin ] 117 | pathGroups: 118 | - pattern: webpack{,-dev-server} 119 | group: external 120 | position: before 121 | - pattern: react{,-dom/*} 122 | group: external 123 | position: before 124 | - pattern: mobx{,-react-lite} 125 | group: external 126 | position: before 127 | - pattern: framer-motion 128 | group: external 129 | position: before 130 | - pattern: '@**' 131 | group: internal 132 | position: before 133 | - pattern: shared/** 134 | group: internal 135 | position: before 136 | - pattern: assets/** 137 | group: sibling 138 | position: after 139 | 140 | class-methods-use-this: off 141 | 142 | overrides: 143 | - files: [ '*.config.ts', 'webpack.*.ts' ] 144 | rules: 145 | no-restricted-exports: off 146 | - files: '*.d.ts' 147 | rules: 148 | no-unused-vars: off 149 | no-restricted-exports: off 150 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # init eslint 2 | 2e747e348b70c82afdb97503b1f53be6e1c77e49 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .yarn/* 27 | !.yarn/patches 28 | !.yarn/plugins 29 | !.yarn/releases 30 | !.yarn/sdks 31 | !.yarn/versions 32 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | useTabs: true 3 | tabWidth: 4 4 | trailingComma: all 5 | printWidth: 100 6 | endOfLine: lf 7 | arrowParens: avoid 8 | jsxSingleQuote: true 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | defaultSemverRangePrefix: "" 4 | 5 | enableGlobalCache: false 6 | 7 | nodeLinker: node-modules 8 | 9 | yarnPath: .yarn/releases/yarn-4.3.1.cjs 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AltHome logo 2 | 3 | **AltHome** is a replacement for the stock home app on LG TVs running webOS 6+. 4 | 5 | Track development status [here](https://github.com/users/kitsuned/projects/1). 6 | 7 | Licensed under GNU Public License 2.0. 8 | -------------------------------------------------------------------------------- /agent/.containerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | Containerfile 4 | 5 | .gitignore 6 | .containerignore 7 | -------------------------------------------------------------------------------- /agent/.gitignore: -------------------------------------------------------------------------------- 1 | ### Rust template 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 8 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 9 | Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # MSVC Windows builds of rustc generate these, which store debugging information 15 | *.pdb 16 | 17 | /agentd* 18 | -------------------------------------------------------------------------------- /agent/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "althome_agentd" 7 | version = "0.1.0" 8 | dependencies = [ 9 | "mnt", 10 | "nix", 11 | "serde", 12 | "serde_json", 13 | ] 14 | 15 | [[package]] 16 | name = "autocfg" 17 | version = "1.1.0" 18 | source = "registry+https://github.com/rust-lang/crates.io-index" 19 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 20 | 21 | [[package]] 22 | name = "bitflags" 23 | version = "1.3.2" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 26 | 27 | [[package]] 28 | name = "cfg-if" 29 | version = "1.0.0" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 32 | 33 | [[package]] 34 | name = "itoa" 35 | version = "1.0.6" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" 38 | 39 | [[package]] 40 | name = "libc" 41 | version = "0.2.140" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" 44 | 45 | [[package]] 46 | name = "memoffset" 47 | version = "0.7.1" 48 | source = "registry+https://github.com/rust-lang/crates.io-index" 49 | checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" 50 | dependencies = [ 51 | "autocfg", 52 | ] 53 | 54 | [[package]] 55 | name = "mnt" 56 | version = "0.3.1" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "1587ebb20a5b04738f16cffa7e2526f1b8496b84f92920facd518362ff1559eb" 59 | dependencies = [ 60 | "libc", 61 | ] 62 | 63 | [[package]] 64 | name = "nix" 65 | version = "0.26.2" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a" 68 | dependencies = [ 69 | "bitflags", 70 | "cfg-if", 71 | "libc", 72 | "memoffset", 73 | "pin-utils", 74 | "static_assertions", 75 | ] 76 | 77 | [[package]] 78 | name = "pin-utils" 79 | version = "0.1.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 82 | 83 | [[package]] 84 | name = "proc-macro2" 85 | version = "1.0.55" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "1d0dd4be24fcdcfeaa12a432d588dc59bbad6cad3510c67e74a2b6b2fc950564" 88 | dependencies = [ 89 | "unicode-ident", 90 | ] 91 | 92 | [[package]] 93 | name = "quote" 94 | version = "1.0.26" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" 97 | dependencies = [ 98 | "proc-macro2", 99 | ] 100 | 101 | [[package]] 102 | name = "ryu" 103 | version = "1.0.13" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" 106 | 107 | [[package]] 108 | name = "serde" 109 | version = "1.0.159" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" 112 | dependencies = [ 113 | "serde_derive", 114 | ] 115 | 116 | [[package]] 117 | name = "serde_derive" 118 | version = "1.0.159" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" 121 | dependencies = [ 122 | "proc-macro2", 123 | "quote", 124 | "syn", 125 | ] 126 | 127 | [[package]] 128 | name = "serde_json" 129 | version = "1.0.95" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" 132 | dependencies = [ 133 | "itoa", 134 | "ryu", 135 | "serde", 136 | ] 137 | 138 | [[package]] 139 | name = "static_assertions" 140 | version = "1.1.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 143 | 144 | [[package]] 145 | name = "syn" 146 | version = "2.0.13" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" 149 | dependencies = [ 150 | "proc-macro2", 151 | "quote", 152 | "unicode-ident", 153 | ] 154 | 155 | [[package]] 156 | name = "unicode-ident" 157 | version = "1.0.8" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" 160 | -------------------------------------------------------------------------------- /agent/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "althome_agentd" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [profile.release] 7 | lto = true 8 | strip = true 9 | opt-level = "z" 10 | 11 | [dependencies] 12 | mnt = "0.3.1" 13 | nix = { version = "0.26.2", features = ["mount"] } 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_json = "1.0" 16 | -------------------------------------------------------------------------------- /agent/Containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/rust:1-slim-buster AS builder 2 | 3 | RUN apt-get update -y && \ 4 | apt-get install -y gcc-arm-linux-gnueabihf patchelf upx 5 | 6 | RUN rustup target add armv7-unknown-linux-gnueabihf 7 | 8 | WORKDIR /app 9 | 10 | ENV CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc 11 | 12 | COPY . . 13 | 14 | RUN ./build 15 | -------------------------------------------------------------------------------- /agent/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -exo pipefail 3 | 4 | cargo build --release --target armv7-unknown-linux-gnueabihf 5 | 6 | cp -R ./target/armv7-unknown-linux-gnueabihf/release/althome_agentd agentd-armhf-release 7 | 8 | patchelf --set-interpreter /lib/ld-linux.so.3 \ 9 | --replace-needed ld-linux-armhf.so.3 ld-linux.so.3 \ 10 | agentd-armhf-release 11 | 12 | upx agentd-armhf-release 13 | -------------------------------------------------------------------------------- /agent/src/luna.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::process::{Command, Stdio}; 3 | use serde::{Serialize, de::DeserializeOwned}; 4 | 5 | pub fn send(url: &str, message: T) -> Result> 6 | where T: Serialize, 7 | R: DeserializeOwned 8 | { 9 | let message = serde_json::to_string(&message).unwrap(); 10 | 11 | let result = Command::new("luna-send") 12 | .args(["-n", "1"]) 13 | .arg(url) 14 | .arg(message) 15 | .output()? 16 | .stdout; 17 | 18 | let response: R = serde_json::from_slice(result.as_slice())?; 19 | 20 | Ok(response) 21 | } 22 | 23 | pub enum LunaHubControlAction { 24 | ScanServices, 25 | ScanVolatileDirs, 26 | } 27 | 28 | pub fn control(action: LunaHubControlAction) { 29 | Command::new("ls-control") 30 | .arg(match action { 31 | LunaHubControlAction::ScanServices => "scan-services", 32 | LunaHubControlAction::ScanVolatileDirs => "scan-volatile-dirs" 33 | }) 34 | .stdout(Stdio::null()) 35 | .spawn() 36 | .unwrap(); 37 | } 38 | 39 | pub mod configd { 40 | use std::error::Error; 41 | use std::collections::HashMap; 42 | use std::fmt::Debug; 43 | 44 | use serde::{Deserialize, Serialize}; 45 | use serde::de::DeserializeOwned; 46 | 47 | use crate::luna; 48 | 49 | #[derive(Serialize)] 50 | struct ConfigGetReq { 51 | #[serde(rename = "configNames")] 52 | keys: Vec, 53 | } 54 | 55 | #[derive(Deserialize)] 56 | struct ConfigGetResp { 57 | configs: HashMap, 58 | } 59 | 60 | pub fn get(key: &str) -> Result> where T: DeserializeOwned + Debug + Clone 61 | { 62 | let response = luna::send::>( 63 | "luna://com.webos.service.config/getConfigs", 64 | ConfigGetReq { 65 | keys: vec![key.to_string()], 66 | }, 67 | )?; 68 | 69 | let config = response.configs.get(key).cloned().unwrap(); 70 | 71 | Ok(config) 72 | } 73 | 74 | #[derive(Serialize)] 75 | struct ConfigSetReq where V: Serialize { 76 | configs: HashMap, 77 | } 78 | 79 | #[derive(Deserialize)] 80 | struct ConfigSetResp {} 81 | 82 | pub fn set(key: &str, message: T) where T: Serialize { 83 | luna::send::, ConfigSetResp>( 84 | "luna://com.webos.service.config/setConfigs", 85 | ConfigSetReq { 86 | configs: HashMap::from([(key.to_string(), message)]), 87 | }, 88 | ).unwrap(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /agent/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env::{current_exe, set_current_dir}; 2 | use std::io; 3 | use serde::Deserialize; 4 | 5 | use crate::routines::{elevator, keyfilter, memory_manager, root_syml}; 6 | 7 | mod routines; 8 | mod rewire; 9 | mod luna; 10 | 11 | #[derive(Deserialize, Clone, Debug)] 12 | #[serde(rename_all = "camelCase")] 13 | struct AltHomeSettings { 14 | #[serde(default = "low_memory")] 15 | memory_quirks: bool, 16 | } 17 | 18 | fn low_memory() -> bool { 19 | // TODO implement checks 20 | true 21 | } 22 | 23 | fn setup_env() -> Result<(), io::Error> { 24 | set_current_dir(current_exe().unwrap().parent().unwrap()) 25 | } 26 | 27 | fn main() { 28 | setup_env().unwrap(); 29 | 30 | elevator::elevate("com.kitsuned.althome") 31 | .expect("Failed to elevate app."); 32 | 33 | root_syml::create_symlink() 34 | .expect("Failed to create symbolic link."); 35 | 36 | keyfilter::rewire() 37 | .expect("Failed to rewire KeyFilters configuration."); 38 | 39 | let config = luna::configd::get::("com.kitsuned.althome"); 40 | 41 | let config = match config { 42 | Ok(config) => config, 43 | Err(_) => AltHomeSettings { 44 | memory_quirks: low_memory() 45 | } 46 | }; 47 | 48 | if config.memory_quirks { 49 | memory_manager::rewire() 50 | .expect("Failed to rewire Memory Manager configuration."); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /agent/src/rewire.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io}; 2 | use std::error::Error; 3 | use std::process::{Command, Stdio}; 4 | 5 | use serde::Serialize; 6 | use serde::de::DeserializeOwned; 7 | 8 | #[cfg(all(not(feature = "sandbox"), target_os = "linux"))] 9 | pub fn bind(source: &str, target: &str) -> Result<(), Box> { 10 | use nix::mount::{mount, umount, MsFlags}; 11 | use mnt::{get_mount, MountEntry}; 12 | 13 | while let Some(MountEntry { file, .. }) = get_mount(target)? { 14 | if file.to_str().unwrap() != target { 15 | break; 16 | } 17 | 18 | println!("Cleaning up existing bind mount: {:?}.", file); 19 | 20 | umount(target)?; 21 | } 22 | 23 | const NONE: Option<&'static [u8]> = None; 24 | 25 | mount( 26 | Some(source), 27 | target, 28 | Some(b"bind".as_ref()), 29 | MsFlags::MS_BIND | MsFlags::MS_RDONLY, 30 | NONE, 31 | )?; 32 | 33 | println!("{:?} bound on {:?}.", source, target); 34 | 35 | Ok(()) 36 | } 37 | 38 | #[cfg(any(feature = "sandbox", not(target_os = "linux")))] 39 | pub fn bind(_source: &str, _target: &str) -> Result<(), Box> { 40 | unimplemented!(); 41 | } 42 | 43 | pub fn create_root_directory(file_path: &str) -> Result<(), io::Error> { 44 | use std::path::PathBuf; 45 | 46 | let root = PathBuf::from(file_path) 47 | .parent() 48 | .unwrap() 49 | .to_owned(); 50 | 51 | fs::create_dir_all(root) 52 | } 53 | 54 | pub fn rewire(path: &str, f: F) -> Result<(), Box> 55 | where T: Serialize + DeserializeOwned, 56 | F: for<'f> FnOnce(&'f mut T) 57 | { 58 | let manifest = fs::read_to_string(path)?; 59 | 60 | let mut manifest: T = serde_json::from_str(manifest.as_str())?; 61 | 62 | f(&mut manifest); 63 | 64 | let serialized = serde_json::to_string(&manifest)?; 65 | 66 | let custom = String::from("/var/rebound") + path; 67 | 68 | create_root_directory(custom.as_str())?; 69 | 70 | fs::write(custom.to_owned(), serialized)?; 71 | 72 | if cfg!(not(feature = "sandbox")) { 73 | bind(custom.as_str(), path) 74 | } else { 75 | Ok(()) 76 | } 77 | } 78 | 79 | pub fn restart_unit(unit: &str) -> Result<(), Box> { 80 | Command::new("systemctl") 81 | .arg("--no-block") 82 | .arg("restart") 83 | .arg(unit) 84 | .stderr(Stdio::null()) 85 | .spawn()?; 86 | 87 | println!("Unit {} restarted.", unit); 88 | 89 | Ok(()) 90 | } 91 | 92 | pub fn kill_all(process_name: &str) { 93 | Command::new("killall") 94 | .arg(process_name) 95 | .stderr(Stdio::null()) 96 | .spawn() 97 | .ok(); 98 | } 99 | -------------------------------------------------------------------------------- /agent/src/routines.rs: -------------------------------------------------------------------------------- 1 | pub mod memory_manager; 2 | pub mod keyfilter; 3 | pub mod elevator; 4 | pub mod root_syml; 5 | -------------------------------------------------------------------------------- /agent/src/routines/elevator.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io}; 2 | use std::collections::HashMap; 3 | use std::path::Path; 4 | 5 | use serde_json::{json, Value}; 6 | use serde::{Serialize, Deserialize}; 7 | 8 | use crate::luna; 9 | use crate::luna::LunaHubControlAction; 10 | 11 | #[derive(Serialize, Deserialize, Debug)] 12 | struct Manifest { 13 | #[serde(rename = "clientPermissionFiles")] 14 | client_permission_files: Vec, 15 | 16 | #[serde(flatten)] 17 | rest: HashMap, 18 | } 19 | 20 | fn rescan() { 21 | luna::control(LunaHubControlAction::ScanVolatileDirs); 22 | luna::control(LunaHubControlAction::ScanServices); 23 | } 24 | 25 | pub fn elevate(app_id: &str) -> Result<(), io::Error> { 26 | let client_permissions_path = 27 | format!("/var/luna-service2-dev/client-permissions.d/{}.all.json", app_id); 28 | let manifest_path = 29 | format!("/var/luna-service2-dev/manifests.d/{}.json", app_id); 30 | 31 | if !Path::new(client_permissions_path.as_str()).exists() { 32 | let client_permissions = json!({ 33 | format!("{}-*", app_id): [ 34 | "all", 35 | ], 36 | }); 37 | 38 | fs::write(&client_permissions_path, client_permissions.to_string())?; 39 | 40 | println!("Created extra client permissions file."); 41 | } 42 | 43 | let manifest = fs::read_to_string(&manifest_path)?; 44 | let mut manifest: Manifest = serde_json::from_str(manifest.as_str())?; 45 | 46 | if manifest.client_permission_files.contains(&client_permissions_path) { 47 | return Ok(()); 48 | } 49 | 50 | manifest.client_permission_files.push(client_permissions_path); 51 | manifest.client_permission_files.sort_unstable(); 52 | manifest.client_permission_files.dedup(); 53 | 54 | let manifest = serde_json::to_string(&manifest)?; 55 | 56 | fs::write(&manifest_path, manifest)?; 57 | 58 | println!("Rewired manifest file."); 59 | 60 | rescan(); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /agent/src/routines/keyfilter.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::error::Error; 3 | use std::time::{SystemTime, UNIX_EPOCH}; 4 | 5 | use serde::{Serialize, Deserialize}; 6 | use crate::luna; 7 | 8 | use crate::rewire::create_root_directory; 9 | 10 | const CUSTOM_KF: &str = "/var/rebound/sysui-keyfilter.js"; 11 | 12 | #[derive(Serialize, Deserialize, Clone, Debug)] 13 | struct KeyFilterPolicy { 14 | file: String, 15 | handler: String, 16 | } 17 | 18 | #[derive(Serialize, Deserialize, Debug)] 19 | struct SurfaceManagerConfig { 20 | #[serde(rename = "keyFilters")] 21 | key_filters: Vec, 22 | } 23 | 24 | fn get_patched_policies() -> Result, Box> { 25 | let sm_config = fs::read_to_string( 26 | "/etc/configd/layers/base/com.webos.surfacemanager.json" 27 | )?; 28 | 29 | let mut sm_config: SurfaceManagerConfig = serde_json::from_str(sm_config.as_str())?; 30 | 31 | // TODO refactor 32 | sm_config.key_filters 33 | .iter_mut() 34 | .find(|x| x.handler == "handleSystemKeys") 35 | .unwrap() 36 | .file = CUSTOM_KF.to_string(); 37 | 38 | sm_config.key_filters.push(KeyFilterPolicy { 39 | file: CUSTOM_KF.to_string(), 40 | handler: String::from("nonce") + SystemTime::now() 41 | .duration_since(UNIX_EPOCH)? 42 | .as_millis() 43 | .to_string() 44 | .as_str(), 45 | }); 46 | 47 | Ok(sm_config.key_filters) 48 | } 49 | 50 | pub fn rewire() -> Result<(), Box> { 51 | let mut keyfilter = fs::read_to_string("/usr/lib/qt5/qml/KeyFilters/systemUi.js") 52 | .expect("Failed to read System UI keyfilter."); 53 | 54 | let shadow = include_str!("shadow.js"); 55 | 56 | keyfilter.push_str(shadow); 57 | 58 | create_root_directory(CUSTOM_KF)?; 59 | 60 | fs::write(CUSTOM_KF, keyfilter)?; 61 | 62 | let policies = get_patched_policies()?; 63 | 64 | luna::configd::set("com.webos.surfacemanager.keyFilters", policies); 65 | 66 | println!("Updated keyfilter configuration."); 67 | 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /agent/src/routines/memory_manager.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::error::Error; 3 | 4 | use serde_json::Value; 5 | use serde::{Serialize, Deserialize}; 6 | 7 | use crate::rewire::{kill_all, restart_unit, rewire as rewire_util}; 8 | 9 | const FACTORY_SETTINGS: &str = "/etc/palm/memorymanager-conf.json"; 10 | 11 | #[derive(Serialize, Deserialize, Debug)] 12 | struct Manifest { 13 | #[serde(rename = "KeepOnLaunchEx")] 14 | keep_alive_extra: Vec, 15 | 16 | #[serde(flatten)] 17 | rest: HashMap, 18 | } 19 | 20 | pub fn rewire() -> Result<(), Box> { 21 | rewire_util(FACTORY_SETTINGS, |manifest: &mut Manifest| { 22 | manifest.keep_alive_extra.retain(|id| id != "com.webos.app.home"); 23 | manifest.keep_alive_extra.push(String::from("com.kitsuned.althome")); 24 | })?; 25 | 26 | restart_unit("memchute.service")?; 27 | 28 | kill_all("com.webos.app.home"); 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /agent/src/routines/root_syml.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::os::unix::fs::symlink; 3 | use std::path::Path; 4 | 5 | pub fn create_symlink() -> Result<(), io::Error> { 6 | if Path::new("./root").is_symlink() { 7 | return Ok(()); 8 | } 9 | 10 | symlink("/", "./root")?; 11 | 12 | println!("Created symbolic link to /."); 13 | 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /agent/src/routines/shadow.js: -------------------------------------------------------------------------------- 1 | var _homeAppId; 2 | 3 | function launchHomeApp(key) { 4 | if (!_homeAppId) { 5 | return; 6 | } 7 | 8 | applicationManager.launch(_homeAppId, JSON.stringify({ 9 | activateType: getActivateType(key), 10 | })); 11 | } 12 | 13 | (function () { 14 | var xhr = new XMLHttpRequest(); 15 | 16 | xhr.onreadystatechange = function () { 17 | _homeAppId = xhr.status === 200 ? 'com.kitsuned.althome' : 'com.webos.app.home'; 18 | }; 19 | 20 | xhr.open('GET', 'file:///media/developer/apps/usr/palm/applications/com.kitsuned.althome/appinfo.json'); 21 | xhr.send(); 22 | })(); 23 | -------------------------------------------------------------------------------- /manifests/app/appinfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "@APP_ID@", 3 | "version": "@APP_VERSION@", 4 | "type": "web", 5 | "main": "index.html", 6 | "title": "AltHome", 7 | "icon": "icon80.png", 8 | "largeIcon": "icon130.png", 9 | "iconColor": "#2f3e46", 10 | "noSplashOnLaunch": true, 11 | "spinnerOnLaunch": false, 12 | "disableBackHistoryAPI": true, 13 | "supportQuickStart": true, 14 | "visible": false, 15 | "defaultWindowType": "floating", 16 | "handlesRelaunch": true 17 | } 18 | -------------------------------------------------------------------------------- /manifests/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /manifests/app/icon130.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/manifests/app/icon130.png -------------------------------------------------------------------------------- /manifests/app/icon320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/manifests/app/icon320.png -------------------------------------------------------------------------------- /manifests/app/icon80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/manifests/app/icon80.png -------------------------------------------------------------------------------- /manifests/service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "service.js" 3 | } 4 | -------------------------------------------------------------------------------- /manifests/service/services.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "@SERVICE_ID@", 3 | "services": [ 4 | { 5 | "name": "@SERVICE_ID@" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "com.kitsuned.althome", 3 | "name": "althome", 4 | "version": "1.0.1", 5 | "description": "AltHome is a replacement for the stock home app on LG TVs running webOS 6+", 6 | "packageManager": "yarn@4.3.1", 7 | "repository": "https://github.com/kitsuned/AltHome", 8 | "scripts": { 9 | "build": "webpack --mode production", 10 | "dev": "webpack serve --mode development", 11 | "lint": "eslint ." 12 | }, 13 | "workspaces": [ 14 | "src/chore/webpack-utils" 15 | ], 16 | "dependencies": { 17 | "@floating-ui/react": "0.22.3", 18 | "clsx": "1.2.1", 19 | "core-js": "3.37.1", 20 | "framer-motion": "9.0.0", 21 | "inversify": "6.0.1", 22 | "mitt": "3.0.0", 23 | "mobx": "6.7.0", 24 | "mobx-react-lite": "3.4.0", 25 | "react": "18.2.0", 26 | "react-dom": "18.2.0", 27 | "reflect-metadata": "0.1.13" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "7.24.7", 31 | "@babel/plugin-proposal-decorators": "7.24.7", 32 | "@babel/plugin-transform-class-properties": "7.24.7", 33 | "@babel/preset-env": "7.24.7", 34 | "@babel/preset-react": "7.24.7", 35 | "@babel/preset-typescript": "7.24.7", 36 | "@babel/register": "7.24.6", 37 | "@types/babel__core": "^7", 38 | "@types/babel__preset-env": "^7", 39 | "@types/babel__register": "^7", 40 | "@types/node": "20.14.9", 41 | "@types/react": "18.0.26", 42 | "@types/react-dom": "18.0.10", 43 | "@typescript-eslint/eslint-plugin": "5.59.1", 44 | "@typescript-eslint/parser": "5.59.1", 45 | "@webosbrew/webos-packager-plugin": "2.0.4", 46 | "autoprefixer": "10.4.13", 47 | "babel-loader": "9.1.3", 48 | "babel-plugin-minify-replace": "0.5.0", 49 | "babel-plugin-transform-typescript-metadata": "0.3.2", 50 | "copy-webpack-plugin": "11.0.0", 51 | "css-loader": "6.7.3", 52 | "eslint": "8.39.0", 53 | "eslint-config-airbnb-base": "15.0.0", 54 | "eslint-config-airbnb-typescript": "17.0.0", 55 | "eslint-config-prettier": "8.8.0", 56 | "eslint-import-resolver-typescript": "3.5.5", 57 | "eslint-plugin-import": "2.27.5", 58 | "eslint-plugin-prettier": "4.2.1", 59 | "eslint-plugin-react": "7.32.2", 60 | "eslint-plugin-react-hooks": "4.6.0", 61 | "eslint-plugin-sonarjs": "0.19.0", 62 | "html-webpack-plugin": "5.5.0", 63 | "mini-css-extract-plugin": "2.7.2", 64 | "postcss": "8.4.21", 65 | "postcss-loader": "7.0.2", 66 | "postcss-preset-env": "8.0.1", 67 | "prettier": "2.8.8", 68 | "sass": "1.57.1", 69 | "sass-loader": "13.2.0", 70 | "style-loader": "3.3.1", 71 | "sucrase": "3.35.0", 72 | "tsconfig-paths-webpack-plugin": "4.0.0", 73 | "typescript": "5.5.2", 74 | "webpack": "5.77.0", 75 | "webpack-cli": "5.0.1", 76 | "webpack-dev-server": "4.11.1" 77 | }, 78 | "dependenciesMeta": { 79 | "core-js": { 80 | "built": false 81 | } 82 | }, 83 | "engines": { 84 | "node": ">=16", 85 | "yarn": ">=3", 86 | "npm": "please-use-yarn" 87 | }, 88 | "os": [ 89 | "!win32" 90 | ], 91 | "browserslist": [ 92 | "Chrome >= 79" 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /service/bus/index.ts: -------------------------------------------------------------------------------- 1 | import palmbus from 'palmbus'; 2 | 3 | import { SERVICE_ID } from '../environment'; 4 | 5 | import { Message } from './message'; 6 | import { AsyncSink } from './sink'; 7 | 8 | type Executor> = (body: T) => AsyncGenerator; 9 | 10 | const extractMethodPath = (path: string): [string, string] => { 11 | const lastSlashIndex = path.lastIndexOf('/'); 12 | 13 | if (lastSlashIndex <= 0) { 14 | return ['/', path.slice(1)]; 15 | } 16 | 17 | return [path.slice(0, lastSlashIndex), path.slice(lastSlashIndex + 1)]; 18 | }; 19 | 20 | export class Service { 21 | private readonly handle: palmbus.Handle; 22 | private readonly serviceId: string; 23 | 24 | private readonly methods: Map> = new Map(); 25 | 26 | public constructor(serviceId?: string) { 27 | this.serviceId = serviceId ?? SERVICE_ID; 28 | 29 | this.handle = new palmbus.Handle(this.serviceId); 30 | 31 | this.handle.addListener('request', this.handleRequest.bind(this)); 32 | 33 | setTimeout(() => { 34 | // TODO do something, huh 35 | }, 10000); 36 | } 37 | 38 | public register = Record>( 39 | method: string, 40 | executor: Executor, 41 | ) { 42 | this.handle.registerMethod(...extractMethodPath(method)); 43 | 44 | this.methods.set(method, executor); 45 | } 46 | 47 | public async *subscribe( 48 | uri: string, 49 | params: Record = {}, 50 | ): AsyncGenerator { 51 | const sink = new AsyncSink(); 52 | const subscription = this.handle.subscribe(uri, JSON.stringify(params)); 53 | 54 | subscription.addListener('response', pMessage => { 55 | sink.push(Message.fromPalmMessage(pMessage).payload); 56 | }); 57 | 58 | try { 59 | yield* sink; 60 | } finally { 61 | subscription.cancel(); 62 | } 63 | } 64 | 65 | public async oneshot(uri: string, params: Record = {}): Promise { 66 | const generator = this.subscribe(uri, params); 67 | 68 | const { value } = await generator.next(); 69 | 70 | await generator.return(null); 71 | 72 | return value!; 73 | } 74 | 75 | private handleRequest(pMessage: palmbus.Message): void { 76 | const message = Message.fromPalmMessage(pMessage); 77 | 78 | Promise.resolve() 79 | .then(() => message.payload) 80 | .then(async body => { 81 | const impl = this.methods.get(message.method)!; 82 | 83 | return this.drainExecutor(impl(body), message); 84 | }) 85 | .catch(e => { 86 | console.error('Failed to handle message:', e); 87 | 88 | message.respond({ 89 | returnValue: false, 90 | errorCode: -1, 91 | errorText: e instanceof Error ? e.message : String(e), 92 | }); 93 | }); 94 | } 95 | 96 | private async drainExecutor(generator: ReturnType>, message: Message) { 97 | const isSubscription = message.payload.subscribe === true; 98 | 99 | let it: IteratorResult; 100 | 101 | do { 102 | it = await generator.next(); 103 | 104 | if (isSubscription || it.done) { 105 | message.respond({ returnValue: true, ...it.value }); 106 | } 107 | } while (!it.done); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /service/bus/message.ts: -------------------------------------------------------------------------------- 1 | import type palmbus from 'palmbus'; 2 | 3 | export class Message = Record> { 4 | protected constructor(private readonly pMessage: palmbus.Message) {} 5 | 6 | public get method(): string { 7 | return this.pMessage.category() + this.pMessage.method(); 8 | } 9 | 10 | public get payload(): T { 11 | return JSON.parse(this.rawPayload) as T; 12 | } 13 | 14 | public get rawPayload(): string { 15 | return this.pMessage.payload(); 16 | } 17 | 18 | public respond(message: Record) { 19 | this.pMessage.respond(JSON.stringify(message)); 20 | } 21 | 22 | public static fromPalmMessage(pMessage: palmbus.Message) { 23 | return new Message(pMessage); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /service/bus/palmbus.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line max-classes-per-file 2 | declare namespace Palmbus { 3 | class Handle { 4 | public constructor(busId: string); 5 | 6 | public call(uri: string, serialized: string): Call; 7 | 8 | public subscribe(uri: string, serialized: string): Call; 9 | 10 | public registerMethod(category: string, method: string): void; 11 | 12 | public addListener(event: 'request', listener: (message: Message) => any): this; 13 | public addListener(event: 'cancel', listener: (message: Message) => any): this; 14 | } 15 | 16 | class Message { 17 | public constructor(serialized: string, handle: Handle); 18 | 19 | public category(): string; 20 | 21 | public method(): string; 22 | 23 | public isSubscription(): boolean; 24 | 25 | public uniqueToken(): string; 26 | 27 | public token(): string; 28 | 29 | public payload(): string; 30 | 31 | public respond(serialized: string): string; 32 | } 33 | 34 | class Call { 35 | public addListener(event: 'response', listener: (message: Message) => any): this; 36 | 37 | public cancel(): void; 38 | } 39 | } 40 | 41 | declare module 'palmbus' { 42 | export = Palmbus; 43 | } 44 | -------------------------------------------------------------------------------- /service/bus/sink.ts: -------------------------------------------------------------------------------- 1 | class Deferred { 2 | public promise: Promise; 3 | public resolved: boolean = false; 4 | 5 | private resolver!: (value: T | PromiseLike) => void; 6 | 7 | public constructor() { 8 | this.promise = new Promise(resolve => { 9 | this.resolver = resolve; 10 | }); 11 | } 12 | 13 | public resolve(value: T | PromiseLike) { 14 | this.resolved = true; 15 | this.resolver(value); 16 | } 17 | } 18 | 19 | export class AsyncSink implements AsyncIterator { 20 | private queue: T[] = []; 21 | private backPressure: Deferred = new Deferred(); 22 | 23 | public [Symbol.asyncIterator](): AsyncIterator { 24 | return this; 25 | } 26 | 27 | public push(value: T) { 28 | if (!this.backPressure.resolved) { 29 | this.backPressure.resolve(value); 30 | return; 31 | } 32 | 33 | this.queue.push(value); 34 | } 35 | 36 | public async next(): Promise> { 37 | const value = 38 | this.queue.length === 0 ? await this.backPressure.promise : this.queue.shift()!; 39 | 40 | this.backPressure = new Deferred(); 41 | 42 | return { done: false, value }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /service/environment.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import process from 'process'; 3 | 4 | // eslint-disable-next-line 5 | export const APP_ID = process.env.APP_ID; 6 | // eslint-disable-next-line 7 | export const SERVICE_ID = process.env.SERVICE_ID; 8 | export const SERVICE_ROOT_DIR = process.cwd(); 9 | export const APP_ROOT_DIR = join(SERVICE_ROOT_DIR, `../../applications/${APP_ID}`); 10 | -------------------------------------------------------------------------------- /service/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const process: { 2 | env: { 3 | SERVICE_ID: string; 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /service/index.ts: -------------------------------------------------------------------------------- 1 | import { Service } from './bus'; 2 | import { routines } from './routines'; 3 | 4 | const service = new Service(); 5 | 6 | service.register('/hello', async function* (message) { 7 | for (const ctor of routines) { 8 | const routine = new ctor(); 9 | 10 | yield { done: false, status: `apply ${routine.id}` }; 11 | 12 | await routine.apply(); 13 | } 14 | 15 | return { done: true, message: 'My Final Message. Goodbye' }; 16 | }); 17 | -------------------------------------------------------------------------------- /service/routine.ts: -------------------------------------------------------------------------------- 1 | export interface Routine { 2 | readonly id: string; 3 | 4 | apply(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /service/routines/index.ts: -------------------------------------------------------------------------------- 1 | import type { Routine } from '../routine'; 2 | 3 | import { RootSymlRoutine } from './root-syml.routine'; 4 | 5 | export const routines: (typeof Routine)[] = [RootSymlRoutine]; 6 | -------------------------------------------------------------------------------- /service/routines/root-syml.routine.ts: -------------------------------------------------------------------------------- 1 | import { promises } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | import { APP_ROOT_DIR } from '../environment'; 5 | import type { Routine } from '../routine'; 6 | 7 | class InvalidLinkError extends Error { 8 | public constructor(message) { 9 | super(message); 10 | 11 | Object.setPrototypeOf(this, InvalidLinkError.prototype); 12 | } 13 | } 14 | 15 | export class RootSymlRoutine implements Routine { 16 | public readonly id = 'root-syml'; 17 | 18 | private readonly linkPath = join(APP_ROOT_DIR, 'root'); 19 | private readonly linkTarget = '/'; 20 | 21 | public async apply() { 22 | try { 23 | const stat = await promises.lstat(this.linkPath); 24 | 25 | if (!stat.isSymbolicLink()) { 26 | throw new InvalidLinkError(`${this.linkPath} is not a symbolic link`); 27 | } 28 | 29 | const target = await promises.readlink(this.linkPath); 30 | 31 | if (target !== this.linkTarget) { 32 | throw new InvalidLinkError(`${this.linkTarget} points to ${target}`); 33 | } 34 | } catch (e) { 35 | if (e instanceof InvalidLinkError) { 36 | console.log('unlinking broken symlink:', e.message); 37 | 38 | await promises.unlink(this.linkPath); 39 | } 40 | 41 | await promises.symlink(this.linkTarget, this.linkPath); 42 | 43 | console.log(`created symlink ${this.linkPath} -> ${this.linkTarget}`); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": "./", 6 | "composite": true, 7 | "types": ["node"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/app.init.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import 'shared/services/services.init'; 4 | import 'features/ribbon/ribbon.init'; 5 | -------------------------------------------------------------------------------- /src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { ContainerProvider, container } from '@di'; 2 | 3 | import { Ribbon } from 'features/ribbon'; 4 | 5 | export const App = (): JSX.Element => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AltHome 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | import './app.init'; 2 | 3 | export { App } from './app'; 4 | -------------------------------------------------------------------------------- /src/app/styles/global.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | width: 100vw; 6 | height: 100vh; 7 | overflow: hidden; 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: any; 3 | export = value; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/hide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/src/assets/hide.png -------------------------------------------------------------------------------- /src/assets/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/src/assets/plus.png -------------------------------------------------------------------------------- /src/assets/remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/src/assets/remove.png -------------------------------------------------------------------------------- /src/assets/swap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitsuned/AltHome/2df03895f24a9f44ea420bdb664d91dd567cd2b4/src/assets/swap.png -------------------------------------------------------------------------------- /src/chore/webpack-utils/definitions.ts: -------------------------------------------------------------------------------- 1 | import type { Configuration as WebpackConfiguration } from 'webpack'; 2 | import type { Configuration as DevServerConfiguration } from 'webpack-dev-server'; 3 | 4 | type Configuration = WebpackConfiguration & DevServerConfiguration & { id: string }; 5 | 6 | export type WebpackEnvironment> = T; 7 | 8 | export type WebpackArgv> = Partial> & { 9 | env: WebpackEnvironment; 10 | }; 11 | 12 | export type WebpackConfigFunction = {}> = ( 13 | env: WebpackEnvironment, 14 | argv: WebpackArgv, 15 | ) => Configuration; 16 | 17 | export type WebpackMultipleConfigurations> = Array< 18 | Configuration | WebpackConfigFunction 19 | >; 20 | -------------------------------------------------------------------------------- /src/chore/webpack-utils/index.ts: -------------------------------------------------------------------------------- 1 | export { JsonTransformer } from './json-transformer'; 2 | 3 | export * from './definitions'; 4 | -------------------------------------------------------------------------------- /src/chore/webpack-utils/json-transformer.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: off */ 2 | export class JsonTransformer { 3 | private readonly definitions: Record; 4 | 5 | public constructor(definitions: Record) { 6 | this.transform = this.transform.bind(this); 7 | 8 | this.definitions = Object.entries(definitions).reduce( 9 | (accumulator, [key, value]) => ({ 10 | ...accumulator, 11 | [`@${key}@`]: value, 12 | }), 13 | {}, 14 | ); 15 | } 16 | 17 | public transform(buffer: Buffer): Buffer { 18 | const transformerScope = this; 19 | 20 | const transformed = JSON.parse(buffer.toString('utf8'), function reviver(...args) { 21 | return transformerScope.reviver.call(transformerScope, this, ...args); 22 | }); 23 | 24 | return Buffer.from(JSON.stringify(transformed), 'utf8'); 25 | } 26 | 27 | private reviver>(objectScope: T, key: keyof T, value: any): any { 28 | const shouldReplaceKey = Object.keys(this.definitions).some(v => 29 | (key as string).includes(v), 30 | ); 31 | 32 | const newValue = typeof value === 'string' ? this.replacer(value) : value; 33 | 34 | if (!shouldReplaceKey) { 35 | return newValue; 36 | } 37 | 38 | delete objectScope[key]; 39 | 40 | objectScope[this.replacer(key as string) as keyof T] = newValue; 41 | 42 | return undefined; 43 | } 44 | 45 | private replacer(value: string): string { 46 | for (const [template, replacement] of Object.entries(this.definitions)) { 47 | value = value.replaceAll(template, replacement); 48 | } 49 | 50 | return value; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/chore/webpack-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-utils", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module" 6 | } 7 | -------------------------------------------------------------------------------- /src/chore/webpack-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "types": [ 8 | "node" 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/features/ribbon/index.ts: -------------------------------------------------------------------------------- 1 | export { Ribbon } from './ui/ribbon'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { MenuAction } from './menu-action.lib'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/lib/menu-action.lib.ts: -------------------------------------------------------------------------------- 1 | export enum MenuAction { 2 | Move = 'Move', 3 | Hide = 'Hide', 4 | Uninstall = 'Uninstall', 5 | } 6 | -------------------------------------------------------------------------------- /src/features/ribbon/ribbon.init.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@di'; 2 | 3 | import { 4 | appDrawerModule, 5 | contextMenuModule, 6 | keyboardModule, 7 | ribbonModule, 8 | scrollModule, 9 | } from './services'; 10 | 11 | container.load(appDrawerModule, contextMenuModule, keyboardModule, scrollModule, ribbonModule); 12 | -------------------------------------------------------------------------------- /src/features/ribbon/services/app-drawer/app-drawer.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { AppDrawerService } from './app-drawer.service'; 4 | 5 | export const appDrawerModule = new ContainerModule(bind => { 6 | bind(AppDrawerService).toSelf(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/features/ribbon/services/app-drawer/app-drawer.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable, reaction } from 'mobx'; 2 | 3 | import { inject, injectable } from 'inversify'; 4 | 5 | import { LauncherService } from 'shared/services/launcher'; 6 | import type { LaunchPointInstance } from 'shared/services/launcher'; 7 | 8 | import { KeyboardService } from '../keyboard'; 9 | 10 | @injectable() 11 | export class AppDrawerService { 12 | public visible: boolean = false; 13 | 14 | private index: number = 0; 15 | private ref: HTMLElement | null = null; 16 | 17 | public constructor( 18 | @inject(LauncherService) private readonly launcherService: LauncherService, 19 | @inject(KeyboardService) keyboardService: KeyboardService, 20 | ) { 21 | makeAutoObservable( 22 | this, 23 | { ref: observable.ref }, 24 | { autoBind: true }, 25 | ); 26 | 27 | reaction( 28 | () => this.ref, 29 | ref => { 30 | if (ref) { 31 | keyboardService.subscribe(ref); 32 | } else { 33 | keyboardService.unsubscribe(); 34 | } 35 | }, 36 | ); 37 | 38 | reaction( 39 | () => this.items.length, 40 | length => { 41 | this.index = Math.min(this.index, length); 42 | }, 43 | ); 44 | 45 | keyboardService.emitter.on('shiftY', this.handleShift); 46 | keyboardService.emitter.on('enter', this.handleEnter); 47 | keyboardService.emitter.on('back', this.handleBack); 48 | } 49 | 50 | public get items() { 51 | return this.launcherService.hidden; 52 | } 53 | 54 | public containerRef(ref: HTMLElement | null) { 55 | this.ref = ref; 56 | this.ref?.focus(); 57 | } 58 | 59 | public isSelected(launchPoint: LaunchPointInstance) { 60 | return this.items[this.index] === launchPoint; 61 | } 62 | 63 | private handleShift(shift: number) { 64 | this.index = Math.min(Math.max(0, this.index + shift), this.items.length - 1); 65 | } 66 | 67 | private handleEnter() { 68 | this.items[this.index]?.show(); 69 | this.visible = false; 70 | } 71 | 72 | private handleBack() { 73 | this.visible = false; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/features/ribbon/services/app-drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { appDrawerModule } from './app-drawer.module'; 2 | export { AppDrawerService } from './app-drawer.service'; 3 | -------------------------------------------------------------------------------- /src/features/ribbon/services/context-menu/context-menu.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { ContextMenuService } from './context-menu.service'; 4 | 5 | export const contextMenuModule = new ContainerModule(bind => { 6 | bind(ContextMenuService).toSelf(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/features/ribbon/services/context-menu/context-menu.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable, reaction } from 'mobx'; 2 | 3 | import { inject, injectable } from 'inversify'; 4 | 5 | import { MenuAction } from '../../lib'; 6 | import { KeyboardService } from '../keyboard'; 7 | import type { RibbonService } from '../ribbon'; 8 | 9 | @injectable() 10 | export class ContextMenuService { 11 | public visible: boolean = false; 12 | 13 | public ribbonService!: RibbonService; 14 | 15 | private action: MenuAction = MenuAction.Move; 16 | private ref: HTMLElement | null = null; 17 | 18 | public constructor(@inject(KeyboardService) keyboardService: KeyboardService) { 19 | makeAutoObservable( 20 | this, 21 | { ref: observable.ref }, 22 | { autoBind: true }, 23 | ); 24 | 25 | reaction( 26 | () => this.ref, 27 | ref => { 28 | if (ref) { 29 | keyboardService.subscribe(ref); 30 | } else { 31 | keyboardService.unsubscribe(); 32 | 33 | this.action = MenuAction.Move; 34 | } 35 | }, 36 | ); 37 | 38 | keyboardService.emitter.on('up', this.handleUp); 39 | keyboardService.emitter.on('down', this.handleDown); 40 | keyboardService.emitter.on('enter', this.handleEnter); 41 | keyboardService.emitter.on('back', this.handleBack); 42 | } 43 | 44 | public containerRef(ref: HTMLElement | null) { 45 | this.ref = ref; 46 | } 47 | 48 | public isActionSelected(action: MenuAction) { 49 | return this.action === action; 50 | } 51 | 52 | private handleUp() { 53 | if (this.action === MenuAction.Hide) { 54 | this.action = MenuAction.Move; 55 | } 56 | 57 | if (this.action === MenuAction.Uninstall) { 58 | this.action = MenuAction.Hide; 59 | } 60 | } 61 | 62 | private handleDown() { 63 | if (this.action === MenuAction.Hide && this.ribbonService.selectedLaunchPoint?.removable) { 64 | this.action = MenuAction.Uninstall; 65 | } 66 | 67 | if (this.action === MenuAction.Move) { 68 | this.action = MenuAction.Hide; 69 | } 70 | } 71 | 72 | private handleEnter() { 73 | const launchPoint = this.ribbonService.selectedLaunchPoint; 74 | 75 | if (!launchPoint) { 76 | return; 77 | } 78 | 79 | this.visible = false; 80 | 81 | if (this.action === MenuAction.Move) { 82 | this.ribbonService.moving = true; 83 | } 84 | 85 | if (this.action === MenuAction.Hide) { 86 | launchPoint.hide(); 87 | } 88 | 89 | if (this.action === MenuAction.Uninstall) { 90 | void launchPoint.uninstall(); 91 | } 92 | } 93 | 94 | private handleBack() { 95 | this.visible = false; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/features/ribbon/services/context-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { contextMenuModule } from './context-menu.module'; 2 | export { ContextMenuService } from './context-menu.service'; 3 | -------------------------------------------------------------------------------- /src/features/ribbon/services/index.ts: -------------------------------------------------------------------------------- 1 | export { appDrawerModule, AppDrawerService } from './app-drawer'; 2 | export { contextMenuModule, ContextMenuService } from './context-menu'; 3 | export { ribbonModule, useRibbonService, RibbonService } from './ribbon'; 4 | export { scrollModule, useScrollService, ScrollService } from './scroll'; 5 | export { keyboardModule, KeyboardService, type KeyboardEmitter } from './keyboard'; 6 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/index.ts: -------------------------------------------------------------------------------- 1 | export { keyboardModule } from './keyboard.module'; 2 | export { KeyboardService } from './keyboard.service'; 3 | export type { KeyboardEmitter } from './keyboard.interface'; 4 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/keyboard.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Emitter } from 'mitt'; 2 | 3 | type Shift = -1 | 1; 4 | 5 | type IsomorphicTimeoutId = ReturnType; 6 | 7 | export type TimerRef = { 8 | id: IsomorphicTimeoutId | null; 9 | fired: boolean; 10 | }; 11 | 12 | export type KeyboardEvents = { 13 | enter: void; 14 | hold: void; 15 | shiftX: Shift; 16 | shiftY: Shift; 17 | left: void; 18 | right: void; 19 | up: void; 20 | down: void; 21 | back: void; 22 | }; 23 | 24 | export type KeyboardEmitter = Emitter; 25 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/keyboard.lib.ts: -------------------------------------------------------------------------------- 1 | export enum ArrowKey { 2 | Left = 'ArrowLeft', 3 | Right = 'ArrowRight', 4 | Up = 'ArrowUp', 5 | Down = 'ArrowDown', 6 | } 7 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/keyboard.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { KeyboardService } from './keyboard.service'; 4 | import { TimerRef } from './timer-ref'; 5 | 6 | export const keyboardModule = new ContainerModule(bind => { 7 | bind(KeyboardService).toSelf().inTransientScope(); 8 | bind(TimerRef).toSelf(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/keyboard.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | import mitt from 'mitt'; 3 | 4 | import type { KeyboardEvents } from './keyboard.interface'; 5 | import { ArrowKey } from './keyboard.lib'; 6 | import { TimerRef } from './timer-ref'; 7 | 8 | const HOLD_THRESHOLD = 500; 9 | 10 | @injectable() 11 | export class KeyboardService { 12 | private ref!: HTMLElement; 13 | 14 | public emitter = mitt(); 15 | 16 | public constructor(@inject(TimerRef) private readonly timerRef: TimerRef) { 17 | this.handleKeyDown = this.handleKeyDown.bind(this); 18 | this.handleKeyUp = this.handleKeyUp.bind(this); 19 | } 20 | 21 | public subscribe(ref: HTMLElement = document.body) { 22 | this.ref = ref; 23 | this.ref.addEventListener('keydown', this.handleKeyDown); 24 | this.ref.addEventListener('keyup', this.handleKeyUp); 25 | } 26 | 27 | public unsubscribe() { 28 | this.ref.removeEventListener('keydown', this.handleKeyDown); 29 | this.ref.removeEventListener('keyup', this.handleKeyUp); 30 | } 31 | 32 | private handleKeyboardEvent(event: KeyboardEvent) { 33 | event.stopPropagation(); 34 | } 35 | 36 | private handleKeyDown(event: KeyboardEvent) { 37 | this.handleKeyboardEvent(event); 38 | 39 | if (event.key === 'GoBack') { 40 | this.emitter.emit('back'); 41 | } 42 | 43 | if (event.key === 'Enter') { 44 | this.handleEnterKeyDown(); 45 | } 46 | 47 | if (event.key.startsWith('Arrow')) { 48 | this.handleArrowKeyDown(event); 49 | } 50 | } 51 | 52 | private handleKeyUp(event: KeyboardEvent) { 53 | this.handleKeyboardEvent(event); 54 | 55 | if (event.key === 'Enter') { 56 | this.handleEnterKeyUp(); 57 | } 58 | } 59 | 60 | private handleEnterKeyDown() { 61 | if (this.timerRef.id !== null) { 62 | return; 63 | } 64 | 65 | this.timerRef.id = setTimeout(() => { 66 | this.timerRef.id = null; 67 | this.timerRef.fired = true; 68 | 69 | this.emitter.emit('hold'); 70 | }, HOLD_THRESHOLD); 71 | } 72 | 73 | private handleEnterKeyUp() { 74 | if (this.timerRef.id !== null) { 75 | if (this.timerRef.id) { 76 | clearTimeout(this.timerRef.id); 77 | } 78 | 79 | this.timerRef.id = null; 80 | 81 | if (!this.timerRef.fired) { 82 | this.emitter.emit('enter'); 83 | } 84 | 85 | this.timerRef.fired = false; 86 | } 87 | } 88 | 89 | private handleArrowKeyDown({ key }: KeyboardEvent) { 90 | switch (key) { 91 | case ArrowKey.Left: 92 | this.emitter.emit('shiftX', -1); 93 | this.emitter.emit('left'); 94 | break; 95 | case ArrowKey.Right: 96 | this.emitter.emit('shiftX', 1); 97 | this.emitter.emit('right'); 98 | break; 99 | case ArrowKey.Up: 100 | this.emitter.emit('shiftY', -1); 101 | this.emitter.emit('up'); 102 | break; 103 | case ArrowKey.Down: 104 | this.emitter.emit('shiftY', 1); 105 | this.emitter.emit('down'); 106 | break; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/timer-ref/index.ts: -------------------------------------------------------------------------------- 1 | export { TimerRef } from './timer-ref.store'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/services/keyboard/timer-ref/timer-ref.store.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | 3 | type IsomorphicTimeoutId = ReturnType; 4 | 5 | @injectable() 6 | export class TimerRef { 7 | public id: IsomorphicTimeoutId | null = null; 8 | public fired: boolean = false; 9 | } 10 | -------------------------------------------------------------------------------- /src/features/ribbon/services/ribbon/index.ts: -------------------------------------------------------------------------------- 1 | export { ribbonModule, useRibbonService } from './ribbon.module'; 2 | export { RibbonService } from './ribbon.service'; 3 | -------------------------------------------------------------------------------- /src/features/ribbon/services/ribbon/ribbon.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { useContainer } from '@di'; 4 | 5 | import { RibbonService } from './ribbon.service'; 6 | 7 | export const ribbonModule = new ContainerModule(bind => { 8 | bind(RibbonService).toSelf(); 9 | }); 10 | 11 | export const useRibbonService = () => useContainer().get(RibbonService); 12 | -------------------------------------------------------------------------------- /src/features/ribbon/services/ribbon/ribbon.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, reaction, when } from 'mobx'; 2 | 3 | import { animationControls } from 'framer-motion'; 4 | 5 | import { inject, injectable } from 'inversify'; 6 | 7 | import { Intent } from 'shared/api/webos.d'; 8 | import type { LaunchPointInstance } from 'shared/services/launcher'; 9 | import { LauncherService } from 'shared/services/launcher'; 10 | import { LifecycleManagerService } from 'shared/services/lifecycle-manager'; 11 | 12 | import { AppDrawerService } from '../app-drawer'; 13 | import { ContextMenuService } from '../context-menu'; 14 | import { KeyboardService } from '../keyboard'; 15 | import { ScrollService } from '../scroll'; 16 | 17 | @injectable() 18 | export class RibbonService { 19 | public visible: boolean = false; 20 | public moving: boolean = false; 21 | public controls = animationControls(); 22 | 23 | private transition: boolean = false; 24 | private ref: HTMLElement | null = null; 25 | 26 | private index: number | null = null; 27 | 28 | public constructor( 29 | @inject(LauncherService) public readonly launcherService: LauncherService, 30 | @inject(ScrollService) public readonly scrollService: ScrollService, 31 | @inject(AppDrawerService) public readonly appDrawerService: AppDrawerService, 32 | @inject(ContextMenuService) public readonly contextMenuService: ContextMenuService, 33 | @inject(LifecycleManagerService) lifecycleManager: LifecycleManagerService, 34 | @inject(KeyboardService) keyboardService: KeyboardService, 35 | ) { 36 | makeAutoObservable(this, { controls: false }, { autoBind: true }); 37 | 38 | when( 39 | () => this.mounted && this.launcherService.fulfilled, 40 | () => { 41 | this.visible = webOSSystem.launchReason !== 'preload'; 42 | }, 43 | ); 44 | 45 | reaction( 46 | () => this.visible, 47 | async visible => { 48 | const definition = visible ? 'show' : 'hide'; 49 | 50 | this.transition = true; 51 | 52 | await this.controls.start(definition); 53 | 54 | this.transition = false; 55 | 56 | lifecycleManager[definition](); 57 | }, 58 | ); 59 | 60 | reaction( 61 | () => this.visible, 62 | () => { 63 | this.moving = false; 64 | this.contextMenuService.visible = false; 65 | this.appDrawerService.visible = false; 66 | }, 67 | ); 68 | 69 | reaction( 70 | () => this.index, 71 | index => { 72 | this.scrollService.selectedLaunchPointIndex = index; 73 | }, 74 | ); 75 | 76 | keyboardService.emitter.on('shiftX', this.handleShift); 77 | keyboardService.emitter.on('enter', this.handleEnter); 78 | keyboardService.emitter.on('hold', this.handleHold); 79 | keyboardService.emitter.on('back', this.handleBack); 80 | keyboardService.subscribe(); 81 | 82 | lifecycleManager.emitter.on('intent', this.handleIntent); 83 | lifecycleManager.emitter.on('relaunch', this.toggle); 84 | lifecycleManager.emitter.on('requestHide', this.hide); 85 | 86 | // TODO lazy inject ribbon into ctx menu by symbol 87 | contextMenuService.ribbonService = this; 88 | } 89 | 90 | public get selectedLaunchPoint(): LaunchPointInstance | null { 91 | return this.index !== null ? this.launcherService.visible[this.index] : null; 92 | } 93 | 94 | public ribbonRef(ref: HTMLElement | null) { 95 | this.ref = ref; 96 | this.scrollService.container = ref; 97 | this.controls.mount(); 98 | } 99 | 100 | public focusToLaunchPoint(launchPoint: LaunchPointInstance) { 101 | this.index = this.launcherService.visible.indexOf(launchPoint); 102 | } 103 | 104 | private get mounted() { 105 | return Boolean(this.ref); 106 | } 107 | 108 | private toggle() { 109 | if (!this.transition) { 110 | this.visible = !this.visible; 111 | } 112 | } 113 | 114 | private hide() { 115 | this.visible = false; 116 | } 117 | 118 | private focusToFirstVisibleNode() { 119 | for (const [index, child] of Array.from(this.ref?.children ?? []).entries()) { 120 | if (child.getBoundingClientRect().left >= 0) { 121 | this.index = index; 122 | return; 123 | } 124 | } 125 | } 126 | 127 | private handleIntent(intent: Intent) { 128 | if (intent === Intent.AddApps) { 129 | this.contextMenuService.visible = false; 130 | this.appDrawerService.visible = true; 131 | } 132 | } 133 | 134 | private handleShift(shift: number) { 135 | if (this.index === null) { 136 | this.focusToFirstVisibleNode(); 137 | return; 138 | } 139 | 140 | const max = this.launcherService.visible.length - 1; 141 | const target = Math.max(0, Math.min(max, this.index + shift)); 142 | 143 | if (this.moving && this.index !== target) { 144 | // TODO find a better way to handle internal lps 145 | if (this.selectedLaunchPoint && target !== max) { 146 | this.selectedLaunchPoint.move(shift); 147 | } else { 148 | return; 149 | } 150 | } 151 | 152 | this.index = target; 153 | } 154 | 155 | private handleEnter() { 156 | if (this.moving) { 157 | this.moving = false; 158 | return; 159 | } 160 | 161 | void this.selectedLaunchPoint?.launch(); 162 | } 163 | 164 | private handleHold() { 165 | if (this.selectedLaunchPoint?.builtin === false) { 166 | this.contextMenuService.visible = true; 167 | } 168 | } 169 | 170 | private handleBack() { 171 | if (this.moving) { 172 | this.moving = false; 173 | } else { 174 | this.visible = false; 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/features/ribbon/services/scroll/index.ts: -------------------------------------------------------------------------------- 1 | export { scrollModule, useScrollService } from './scroll.module'; 2 | export { ScrollService } from './scroll.service'; 3 | -------------------------------------------------------------------------------- /src/features/ribbon/services/scroll/scroll.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { useContainer } from '@di'; 4 | 5 | import { ScrollService } from './scroll.service'; 6 | 7 | export const scrollModule = new ContainerModule(bind => { 8 | bind(ScrollService).toSelf(); 9 | }); 10 | 11 | export const useScrollService = () => useContainer().get(ScrollService); 12 | -------------------------------------------------------------------------------- /src/features/ribbon/services/scroll/scroll.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable, reaction, when } from 'mobx'; 2 | 3 | import { animate, motionValue } from 'framer-motion'; 4 | 5 | import { inject, injectable } from 'inversify'; 6 | 7 | import { SettingsService } from 'shared/services/settings'; 8 | 9 | @injectable() 10 | export class ScrollService { 11 | public container: HTMLElement | null = null; 12 | public selectedLaunchPointIndex: number | null = null; 13 | 14 | private wheelShift: number = 0; 15 | private scrollPosition = motionValue(0); 16 | 17 | public constructor(@inject(SettingsService) private readonly settingsService: SettingsService) { 18 | makeAutoObservable( 19 | this, 20 | { 21 | container: observable.ref, 22 | containerBox: observable.struct, 23 | scrollPosition: false, 24 | }, 25 | { autoBind: true }, 26 | ); 27 | 28 | when( 29 | () => this.container !== null, 30 | () => { 31 | reaction( 32 | () => this.selectedLaunchPointIndex, 33 | () => { 34 | this.wheelShift = this.focusedElementPosition; 35 | }, 36 | ); 37 | 38 | reaction( 39 | () => this.wheelShift, 40 | () => animate(this.scrollPosition, this.wheelShift), 41 | ); 42 | }, 43 | ); 44 | 45 | this.scrollPosition.on('change', v => { 46 | this.container!.scrollLeft = v; 47 | }); 48 | 49 | document.addEventListener('wheel', this.handleScroll); 50 | } 51 | 52 | public get isAnimating() { 53 | return this.scrollPosition.isAnimating(); 54 | } 55 | 56 | private get focusedElementPosition() { 57 | if (!this.container || this.selectedLaunchPointIndex === null) { 58 | return this.container?.scrollLeft ?? 0; 59 | } 60 | 61 | const element = 62 | this.container.children.length <= this.selectedLaunchPointIndex 63 | ? this.container.lastElementChild! 64 | : this.container.children[this.selectedLaunchPointIndex]; 65 | 66 | const box = element.getBoundingClientRect(); 67 | 68 | const { width: viewportWidth } = document.body.getBoundingClientRect(); 69 | 70 | if (box.left >= 0 && box.right <= viewportWidth) { 71 | return this.container.scrollLeft; 72 | } 73 | 74 | if (box.left < 0) { 75 | return this.container.scrollLeft + box.left; 76 | } 77 | 78 | if (box.right > viewportWidth) { 79 | return this.container.scrollLeft + box.right - viewportWidth; 80 | } 81 | 82 | return 0; 83 | } 84 | 85 | private get shiftThreshold() { 86 | const { scrollWidth, clientWidth } = this.container!; 87 | 88 | return scrollWidth - clientWidth; 89 | } 90 | 91 | private handleScroll({ deltaY }: WheelEvent) { 92 | this.wheelShift += deltaY * this.settingsService.wheelVelocityFactor; 93 | 94 | this.wheelShift = Math.max(0, Math.min(this.shiftThreshold, this.wheelShift)); 95 | 96 | // TODO react to isAnimating 97 | // this.lrudService.blur(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonAppDrawer } from './ribbon-app-drawer.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-item/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonAppDrawerItem } from './ribbon-app-drawer-item.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-item/ribbon-app-drawer-item.component.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from 'react'; 2 | import type { CSSProperties } from 'react'; 3 | 4 | import { computed } from 'mobx'; 5 | import { observer } from 'mobx-react-lite'; 6 | 7 | import clsx from 'clsx'; 8 | 9 | import { useRibbonService } from 'features/ribbon/services'; 10 | 11 | import type { RibbonAppDrawerItemProps } from './ribbon-app-drawer-item.interface'; 12 | import s from './ribbon-app-drawer-item.module.scss'; 13 | 14 | export const RibbonAppDrawerItem = observer( 15 | ({ launchPoint }: RibbonAppDrawerItemProps): JSX.Element => { 16 | const svc = useRibbonService(); 17 | 18 | const elementRef = useRef(null); 19 | 20 | const isSelected = computed(() => svc.appDrawerService.isSelected(launchPoint)).get(); 21 | 22 | const style = useMemo( 23 | () => ({ '--icon-color': launchPoint.iconColor } as CSSProperties), 24 | [launchPoint.iconColor], 25 | ); 26 | 27 | useEffect(() => { 28 | if (isSelected) { 29 | elementRef.current?.scrollIntoView({ block: 'nearest' }); 30 | } 31 | }, [isSelected, launchPoint]); 32 | 33 | return ( 34 | 43 | ); 44 | }, 45 | ); 46 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-item/ribbon-app-drawer-item.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LaunchPoint } from 'shared/services/launcher'; 2 | 3 | export type RibbonAppDrawerItemProps = { 4 | launchPoint: LaunchPoint; 5 | }; 6 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-item/ribbon-app-drawer-item.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | --icon-color: #fff; 3 | 4 | appearance: none; 5 | background-color: transparent; 6 | border: none; 7 | color: inherit; 8 | text-align: left; 9 | transition: background-color .1s ease, color .1s ease; 10 | 11 | line-height: 2rem; 12 | font-size: 1.75rem; 13 | padding: 1.5rem 2rem; 14 | 15 | display: flex; 16 | align-items: center; 17 | width: 100%; 18 | gap: 1.5ch; 19 | } 20 | 21 | .button.focused { 22 | background-color: #c3c6c414; 23 | } 24 | 25 | .icon { 26 | height: 4rem; 27 | width: 4rem; 28 | background-color: var(--icon-color); 29 | border-radius: .5rem; 30 | } 31 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-list/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonAppDrawerList } from './ribbon-app-drawer-list.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-list/ribbon-app-drawer-list.component.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import { useRibbonService } from 'features/ribbon/services'; 4 | 5 | import { RibbonAppDrawerItem } from '../ribbon-app-drawer-item'; 6 | 7 | import s from './ribbon-app-drawer-list.module.scss'; 8 | 9 | export const RibbonAppDrawerList = observer((): JSX.Element => { 10 | const svc = useRibbonService(); 11 | 12 | return ( 13 |
14 | {svc.launcherService.hidden.map(lp => ( 15 | 16 | ))} 17 |
18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer-list/ribbon-app-drawer-list.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: auto; 5 | } 6 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer.component.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import type { MotionProps, Variants } from 'framer-motion'; 3 | 4 | import { FloatingPortal } from '@floating-ui/react'; 5 | 6 | import { RibbonAppDrawerList } from './ribbon-app-drawer-list'; 7 | import s from './ribbon-app-drawer.module.scss'; 8 | 9 | const dialogVariants: Variants = { 10 | active: { 11 | translateX: 0, 12 | }, 13 | exit: { 14 | translateX: 640, 15 | }, 16 | }; 17 | 18 | const backdropVariants: Variants = { 19 | active: { 20 | opacity: 0.6, 21 | }, 22 | exit: { 23 | opacity: 0, 24 | }, 25 | }; 26 | 27 | const animationMixin: MotionProps = { 28 | initial: 'exit', 29 | animate: 'active', 30 | exit: 'exit', 31 | transition: { 32 | bounce: 0, 33 | }, 34 | }; 35 | 36 | export const RibbonAppDrawer = (): JSX.Element => ( 37 | 38 | 39 | 40 | 41 |

Apps

42 | 43 | 44 |
45 |
46 | ); 47 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-app-drawer/ribbon-app-drawer.module.scss: -------------------------------------------------------------------------------- 1 | .backdrop { 2 | overflow: hidden; 3 | position: fixed; 4 | 5 | background: #0e0e0e; 6 | will-change: opacity; 7 | inset: 0; 8 | opacity: 0; 9 | } 10 | 11 | .drawer { 12 | position: fixed; 13 | top: 0; 14 | bottom: 0; 15 | right: 0; 16 | will-change: transform; 17 | transform: translateZ(1px); 18 | 19 | display: flex; 20 | flex-direction: column; 21 | 22 | width: 640px; 23 | height: 100%; 24 | 25 | color: #e3e3e3; 26 | background: #1f1f1f; 27 | } 28 | 29 | .header { 30 | margin: 0 0 1rem; 31 | padding: 2rem; 32 | background: #2d2f31; 33 | } 34 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-card/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonCard } from './ribbon-card.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-card/ribbon-card.component.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef } from 'react'; 2 | import type { CSSProperties } from 'react'; 3 | 4 | import { computed } from 'mobx'; 5 | import { observer } from 'mobx-react-lite'; 6 | 7 | import { AnimatePresence, motion } from 'framer-motion'; 8 | import type { MotionProps } from 'framer-motion'; 9 | 10 | import { FloatingPortal } from '@floating-ui/react'; 11 | 12 | import { useRibbonService } from '../../services'; 13 | import { RibbonContextMenu } from '../ribbon-context-menu'; 14 | 15 | import type { RibbonCardProps } from './ribbon-card.interface'; 16 | import s from './ribbon-card.module.scss'; 17 | 18 | const motionProps: MotionProps = { 19 | variants: { 20 | selected: { 21 | x: 3.4, 22 | height: 270, 23 | }, 24 | moving: { 25 | x: 8.7, 26 | y: -32, 27 | height: 270, 28 | }, 29 | }, 30 | }; 31 | 32 | export const RibbonCard = observer(({ position, launchPoint }) => { 33 | const svc = useRibbonService(); 34 | 35 | const cardRef = useRef(null); 36 | 37 | const isSelected = computed(() => svc.selectedLaunchPoint === launchPoint).get(); 38 | 39 | const showContextMenu = isSelected && svc.contextMenuService.visible; 40 | 41 | const style = useMemo( 42 | () => ({ 43 | zIndex: isSelected ? 1000 : position + 5, 44 | '--card-color': launchPoint.iconColor, 45 | }), 46 | [position, isSelected, launchPoint.iconColor], 47 | ); 48 | 49 | const handleMouseOver = useCallback(() => { 50 | if (!svc.scrollService.isAnimating) { 51 | svc.focusToLaunchPoint(launchPoint); 52 | } 53 | }, [svc, launchPoint]); 54 | 55 | const handleClick = useCallback(() => launchPoint.launch(), [launchPoint]); 56 | 57 | return ( 58 | <> 59 | 69 | 70 | 71 | 72 | 73 | {showContextMenu && ( 74 | 75 | 80 | 81 | )} 82 | 83 | 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-card/ribbon-card.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LaunchPointInstance } from 'shared/services/launcher'; 2 | 3 | export type RibbonCardProps = { 4 | position: number; 5 | launchPoint: LaunchPointInstance; 6 | }; 7 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-card/ribbon-card.module.scss: -------------------------------------------------------------------------------- 1 | $card-width: 142px; 2 | $card-height: 230px; 3 | 4 | .card { 5 | --card-color: #ffffff; 6 | 7 | position: relative; 8 | 9 | flex-shrink: 0; 10 | 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | 15 | appearance: none; 16 | background: none; 17 | outline: none; 18 | 19 | width: $card-width; 20 | height: $card-height; 21 | 22 | padding: 0; 23 | margin-left: 2px; 24 | border: 0; 25 | 26 | &::after { 27 | content: ''; 28 | z-index: -1; 29 | 30 | position: absolute; 31 | inset: 0; 32 | 33 | transform: skewX(-9.5deg); 34 | 35 | background-color: var(--card-color); 36 | } 37 | } 38 | 39 | .icon { 40 | width: 115px; 41 | height: 115px; 42 | } 43 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonContextMenu } from './ribbon-context-menu.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu-action/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonContextMenuAction } from './ribbon-context-menu-action.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu-action/ribbon-context-menu-action.component.tsx: -------------------------------------------------------------------------------- 1 | import { computed } from 'mobx'; 2 | import { observer } from 'mobx-react-lite'; 3 | 4 | import clsx from 'clsx'; 5 | 6 | import { MenuAction } from 'features/ribbon/lib'; 7 | import { useRibbonService } from 'features/ribbon/services'; 8 | 9 | import type { RibbonContextMenuActionProps } from './ribbon-context-menu-action.interface'; 10 | import { mapMenuActionToIcon } from './ribbon-context-menu-action.lib'; 11 | import s from './ribbon-context-menu-action.module.scss'; 12 | 13 | export const RibbonContextMenuAction = observer( 14 | ({ action }: RibbonContextMenuActionProps): JSX.Element => { 15 | const svc = useRibbonService(); 16 | 17 | const focused = computed(() => svc.contextMenuService.isActionSelected(action)).get(); 18 | 19 | return ( 20 | 31 | ); 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu-action/ribbon-context-menu-action.interface.ts: -------------------------------------------------------------------------------- 1 | import type { MenuAction } from 'features/ribbon/services'; 2 | 3 | export type RibbonContextMenuActionProps = { 4 | action: MenuAction; 5 | }; 6 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu-action/ribbon-context-menu-action.lib.ts: -------------------------------------------------------------------------------- 1 | import { MenuAction } from 'features/ribbon/lib'; 2 | 3 | import hide from 'assets/hide.png'; 4 | import remove from 'assets/remove.png'; 5 | import swap from 'assets/swap.png'; 6 | 7 | const map: Record = { 8 | [MenuAction.Hide]: hide, 9 | [MenuAction.Move]: swap, 10 | [MenuAction.Uninstall]: remove, 11 | }; 12 | 13 | export const mapMenuActionToIcon = (action: MenuAction): string => map[action]; 14 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu-action/ribbon-context-menu-action.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | display: flex; 3 | align-items: center; 4 | gap: 1ch; 5 | 6 | background-color: #f8f8f8; 7 | border: none; 8 | color: #00000099; 9 | cursor: pointer; 10 | font-size: 1.625rem; 11 | font-weight: 700; 12 | margin: 0; 13 | min-height: 1em; 14 | line-height: 36px; 15 | outline: 0; 16 | padding: 0.8rem 1.4rem 0.8rem; 17 | text-align: left; 18 | text-decoration: none; 19 | transition: background-color .1s ease; 20 | vertical-align: baseline; 21 | width: 100%; 22 | 23 | &.focused { 24 | background-color: #e0e1e2; 25 | } 26 | 27 | &.danger { 28 | color: #db282899; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu.component.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useLayoutEffect, useRef } from 'react'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import { motion } from 'framer-motion'; 6 | 7 | import { arrow, FloatingArrow, offset, shift, useFloating } from '@floating-ui/react'; 8 | 9 | import { MenuAction } from 'features/ribbon/lib'; 10 | 11 | import { RibbonContextMenuAction } from './ribbon-context-menu-action'; 12 | import type { RibbonContextMenuProps } from './ribbon-context-menu.interface'; 13 | import s from './ribbon-context-menu.module.scss'; 14 | 15 | export const RibbonContextMenu = observer( 16 | forwardRef( 17 | ({ cardRef, removable = true }, ref): JSX.Element => { 18 | const menuRef = useRef(null); 19 | const arrowRef = useRef(null); 20 | 21 | const { strategy, x, y, refs, context } = useFloating({ 22 | placement: 'top', 23 | middleware: [ 24 | offset({ crossAxis: 21, mainAxis: 16 }), 25 | shift(), 26 | arrow({ element: arrowRef }), 27 | ], 28 | }); 29 | 30 | useLayoutEffect(() => { 31 | menuRef.current?.focus(); 32 | refs.setReference(cardRef.current); 33 | }, [cardRef, refs]); 34 | 35 | const unwrapRef = (elem: HTMLDivElement | null) => { 36 | refs.setFloating(elem); 37 | 38 | if (ref) { 39 | if ('current' in ref) { 40 | ref.current = elem; 41 | } else { 42 | ref(elem); 43 | } 44 | } 45 | }; 46 | 47 | return ( 48 | 60 |
61 | 62 | 63 | 64 | 65 | {removable && } 66 |
67 | 68 | 69 |
70 | ); 71 | }, 72 | ), 73 | ); 74 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu.interface.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | export type RibbonContextMenuProps = { 4 | cardRef: React.MutableRefObject; 5 | removable?: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-context-menu/ribbon-context-menu.module.scss: -------------------------------------------------------------------------------- 1 | $bg: #f8f8f8; 2 | 3 | .container { 4 | padding: 0 16px; 5 | } 6 | 7 | .menu { 8 | display: flex; 9 | flex-direction: column; 10 | overflow: hidden; 11 | width: 320px; 12 | border-radius: 12px; 13 | background: $bg; 14 | } 15 | 16 | .arrow { 17 | padding-left: 21px; 18 | fill: $bg; 19 | } 20 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-scroll-trigger/index.ts: -------------------------------------------------------------------------------- 1 | export { RibbonScrollTrigger } from './ribbon-scroll-trigger.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-scroll-trigger/ribbon-scroll-trigger.component.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import type { RibbonScrollTriggerProps } from './ribbon-scroll-trigger.interface'; 4 | import s from './ribbon-scroll-trigger.module.scss'; 5 | 6 | export const RibbonScrollTrigger = memo( 7 | ({ hiddenEdge, onTrigger }: RibbonScrollTriggerProps): JSX.Element => ( 8 | <> 9 | {hiddenEdge !== 'left' && ( 10 |
onTrigger('left')} 13 | onMouseOut={() => onTrigger(null)} 14 | /> 15 | )} 16 | 17 | {hiddenEdge !== 'right' && ( 18 |
onTrigger('right')} 21 | onMouseOut={() => onTrigger(null)} 22 | /> 23 | )} 24 | 25 | ), 26 | ); 27 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-scroll-trigger/ribbon-scroll-trigger.interface.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | type TriggerZone = any; 3 | 4 | export type RibbonScrollTriggerProps = { 5 | hiddenEdge: TriggerZone; 6 | onTrigger(zone: TriggerZone): void; 7 | }; 8 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon-scroll-trigger/ribbon-scroll-trigger.module.scss: -------------------------------------------------------------------------------- 1 | .left, 2 | .right { 3 | position: fixed; 4 | bottom: 0; 5 | 6 | height: 280px; 7 | width: 40px; 8 | } 9 | 10 | .right { 11 | right: 0; 12 | } 13 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon/index.ts: -------------------------------------------------------------------------------- 1 | export { Ribbon } from './ribbon.component'; 2 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon/ribbon.component.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import type { MotionProps } from 'framer-motion'; 5 | 6 | import { useRibbonService } from 'features/ribbon/services'; 7 | 8 | import { RibbonAppDrawer } from '../ribbon-app-drawer'; 9 | import { RibbonCard } from '../ribbon-card'; 10 | 11 | import s from './ribbon.module.scss'; 12 | 13 | const motionProps: MotionProps = { 14 | variants: { 15 | hide: { 16 | y: '105%', 17 | transition: { 18 | duration: 0.5, 19 | }, 20 | }, 21 | show: { 22 | y: 1, 23 | transition: { 24 | ease: 'circOut', 25 | }, 26 | }, 27 | }, 28 | initial: 'hide', 29 | }; 30 | 31 | export const Ribbon = observer(() => { 32 | const svc = useRibbonService(); 33 | 34 | return ( 35 | <> 36 | 42 | {svc.launcherService.visible.map((lp, index) => ( 43 | 44 | ))} 45 | 46 | 47 | {svc.appDrawerService.visible && } 48 | 49 | ); 50 | }); 51 | -------------------------------------------------------------------------------- /src/features/ribbon/ui/ribbon/ribbon.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100vw; 3 | height: 100vh; 4 | } 5 | 6 | .group { 7 | position: fixed; 8 | inset: 0; 9 | 10 | display: flex; 11 | align-items: flex-end; 12 | 13 | overflow-x: scroll; 14 | 15 | &::-webkit-scrollbar { 16 | display: none; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import { App } from './app'; 5 | 6 | import './app/styles/global.scss'; 7 | 8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/shared/api/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.scss' { 2 | const content: Record; 3 | export default content; 4 | } 5 | 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | declare const __DEV__: boolean; 8 | 9 | declare const process: { 10 | env: { 11 | APP_ID: string; 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/shared/api/webos.d.ts: -------------------------------------------------------------------------------- 1 | export enum Intent { 2 | AddApps = 'add_apps', 3 | } 4 | 5 | interface ActivateType { 6 | activateType?: 'home' | string; 7 | 8 | intent?: Intent; 9 | } 10 | 11 | interface InputRegion { 12 | x: number; 13 | y: number; 14 | width: number; 15 | height: number; 16 | } 17 | 18 | declare global { 19 | class PalmServiceBridge { 20 | constructor(serviceId?: string); 21 | 22 | onservicecallback(serializedMessage: string): void; 23 | 24 | call(uri: string, serializedParameters: string): void; 25 | } 26 | 27 | namespace webOSSystem { 28 | const identifier: string; 29 | 30 | const launchParams: ActivateType; 31 | const launchReason: string; 32 | 33 | /** 34 | * Serialized JSON with basic device info. 35 | */ 36 | const deviceInfo: string; 37 | 38 | /** 39 | * Tells compositor to hide the current layer. Works only on webOS 7+. 40 | */ 41 | function hide(): void; 42 | 43 | /** 44 | * Tells compositor to activate the UI layer. 45 | */ 46 | function activate(): void; 47 | 48 | const window: { 49 | /** 50 | * Set keyboard focus 51 | */ 52 | setFocus(focus: boolean): void; 53 | 54 | /** 55 | * Set floating window input region 56 | */ 57 | setInputRegion(regions: [region: InputRegion]): void; 58 | }; 59 | } 60 | 61 | interface Document { 62 | addEventListener( 63 | type: 'webOSRelaunch', 64 | listener: (this: Document, event: CustomEvent) => void, 65 | ): void; 66 | 67 | removeEventListener( 68 | type: 'webOSRelaunch', 69 | listener: (this: Document, event: CustomEvent) => void, 70 | ): void; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/shared/core/di/container.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import type { Container } from 'inversify'; 4 | 5 | type ContainerContextValue = Container | null; 6 | 7 | export const ContainerContext = createContext(null); 8 | 9 | export const useContainer = () => useContext(ContainerContext)!; 10 | -------------------------------------------------------------------------------- /src/shared/core/di/container.provider.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | import type { Container } from 'inversify'; 4 | 5 | import { ContainerContext } from './container.context'; 6 | 7 | type ContainerProviderProps = { 8 | container: Container; 9 | children: React.ReactNode; 10 | }; 11 | 12 | export const ContainerProvider = ({ container, children }: ContainerProviderProps) => ( 13 | {children} 14 | ); 15 | -------------------------------------------------------------------------------- /src/shared/core/di/di.container.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'inversify'; 2 | 3 | export const container = new Container({ 4 | autoBindInjectable: true, 5 | defaultScope: 'Singleton', 6 | }); 7 | -------------------------------------------------------------------------------- /src/shared/core/di/index.ts: -------------------------------------------------------------------------------- 1 | export { container } from './di.container'; 2 | 3 | export { ContainerProvider } from './container.provider'; 4 | export { ContainerContext, useContainer } from './container.context'; 5 | -------------------------------------------------------------------------------- /src/shared/core/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export const throttle = ( 2 | fn: (...args: A) => void, 3 | wait: number, 4 | ): ((...args: A) => void) => { 5 | let timerId: ReturnType; 6 | let lastTick: number = 0; 7 | 8 | return (...args: A) => { 9 | const delta = Date.now() - lastTick; 10 | 11 | clearTimeout(timerId); 12 | 13 | if (delta > wait) { 14 | lastTick = Date.now(); 15 | 16 | fn.apply(this, args); 17 | } else { 18 | timerId = setTimeout(() => fn.apply(this, args), wait); 19 | } 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/shared/services/launcher/api/launch-point.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LaunchPoint } from '../model/launch-point.model'; 2 | 3 | export type LaunchPointInput = { 4 | id: string; 5 | title: string; 6 | 7 | launchPointId: string; 8 | 9 | removable: boolean; 10 | iconColor: string; 11 | 12 | icon: string; 13 | mediumLargeIcon?: string; 14 | largeIcon?: string; 15 | extraLargeIcon?: string; 16 | 17 | builtin?: boolean; 18 | params?: Record; 19 | }; 20 | 21 | export type LaunchPointInstance = LaunchPoint; 22 | 23 | export type LaunchPointFactory = (snapshot: LaunchPointInput) => LaunchPointInstance; 24 | -------------------------------------------------------------------------------- /src/shared/services/launcher/index.ts: -------------------------------------------------------------------------------- 1 | export { launcherModule } from './launcher.module'; 2 | 3 | export { LauncherService } from './model/launcher.service'; 4 | 5 | export { LaunchPoint } from './model/launch-point.model'; 6 | 7 | export type { 8 | LaunchPointInput, 9 | LaunchPointInstance, 10 | LaunchPointFactory, 11 | } from './api/launch-point.interface'; 12 | -------------------------------------------------------------------------------- /src/shared/services/launcher/launcher.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import type { LaunchPointInput, LaunchPointInstance } from './api/launch-point.interface'; 4 | import { launchPointFactorySymbol } from './launcher.tokens'; 5 | import { LaunchPoint } from './model/launch-point.model'; 6 | import { LauncherService } from './model/launcher.service'; 7 | import { 8 | AppManagerProvider, 9 | InputProvider, 10 | InternalProvider, 11 | LaunchPointsProvider, 12 | } from './providers'; 13 | 14 | export const launcherModule = new ContainerModule(bind => { 15 | bind(LauncherService).toSelf(); 16 | bind(LaunchPoint).toSelf().inTransientScope(); 17 | 18 | bind(launchPointFactorySymbol).toFactory( 19 | context => snapshot => context.container.get(LaunchPoint).apply(snapshot), 20 | ); 21 | 22 | bind(LaunchPointsProvider).to(InputProvider); 23 | bind(LaunchPointsProvider).to(AppManagerProvider); 24 | bind(LaunchPointsProvider).to(InternalProvider); 25 | }); 26 | -------------------------------------------------------------------------------- /src/shared/services/launcher/launcher.tokens.ts: -------------------------------------------------------------------------------- 1 | export const launchPointFactorySymbol = Symbol('Factory'); 2 | -------------------------------------------------------------------------------- /src/shared/services/launcher/model/launch-point.model.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | 3 | import type { LaunchPointInput } from '../api/launch-point.interface'; 4 | 5 | import { LauncherService } from './launcher.service'; 6 | 7 | type NonFunctionPropertyNames = { 8 | [K in keyof T]: T[K] extends Function ? never : K; 9 | }[keyof T]; 10 | 11 | type NonFunctionProperties = Pick>; 12 | 13 | @injectable() 14 | export class LaunchPoint { 15 | appId!: string; 16 | title!: string; 17 | launchPointId!: string; 18 | 19 | builtin!: boolean; 20 | removable!: boolean; 21 | 22 | icon!: string; 23 | iconColor!: string; 24 | 25 | params: Record = {}; 26 | 27 | public constructor( 28 | @inject(LauncherService) private readonly launcherService: LauncherService, 29 | ) {} 30 | 31 | public launch(): Promise { 32 | return this.launcherService.launch(this); 33 | } 34 | 35 | public move(shift: number) { 36 | this.launcherService.move(this, shift); 37 | } 38 | 39 | public show() { 40 | this.launcherService.show(this); 41 | } 42 | 43 | public hide() { 44 | this.launcherService.hide(this); 45 | } 46 | 47 | public uninstall() { 48 | return this.launcherService.uninstall(this); 49 | } 50 | 51 | public apply(snapshot: LaunchPointInput): LaunchPoint { 52 | const { 53 | title, 54 | launchPointId, 55 | removable, 56 | iconColor, 57 | builtin = false, 58 | params = {}, 59 | } = snapshot; 60 | 61 | return Object.assign(this, { 62 | appId: snapshot.id, 63 | icon: LaunchPoint.normalizeIcon(snapshot), 64 | title, 65 | launchPointId, 66 | removable, 67 | iconColor, 68 | builtin, 69 | params, 70 | } satisfies NonFunctionProperties); 71 | } 72 | 73 | private static normalizePath(path: string) { 74 | return path.startsWith('/') ? `./root${path}` : path; 75 | } 76 | 77 | private static normalizeIcon(snapshot: LaunchPointInput) { 78 | return LaunchPoint.normalizePath( 79 | snapshot.mediumLargeIcon || 80 | snapshot.largeIcon || 81 | snapshot.extraLargeIcon || 82 | snapshot.icon, 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/shared/services/launcher/model/launcher.service.ts: -------------------------------------------------------------------------------- 1 | import { keys, makeAutoObservable, observable, reaction } from 'mobx'; 2 | import type { ObservableMap } from 'mobx'; 3 | 4 | import { inject, injectable, multiInject } from 'inversify'; 5 | 6 | import { luna } from 'shared/services/luna'; 7 | import { SettingsService } from 'shared/services/settings'; 8 | 9 | import { LifecycleManagerService } from '../../lifecycle-manager'; 10 | import type { 11 | LaunchPointFactory, 12 | LaunchPointInput, 13 | LaunchPointInstance, 14 | } from '../api/launch-point.interface'; 15 | import { launchPointFactorySymbol } from '../launcher.tokens'; 16 | import { LaunchPointsProvider } from '../providers'; 17 | 18 | @injectable() 19 | export class LauncherService { 20 | private readonly launchPointsMap = observable.map(); 21 | 22 | public constructor( 23 | @inject(SettingsService) private readonly settingsService: SettingsService, 24 | @inject(LifecycleManagerService) private readonly lifecycleManager: LifecycleManagerService, 25 | @inject(launchPointFactorySymbol) private readonly launchPointFactory: LaunchPointFactory, 26 | @multiInject(LaunchPointsProvider) private readonly providers: LaunchPointsProvider[], 27 | ) { 28 | makeAutoObservable( 29 | this, 30 | { pickByIds: false }, 31 | { autoBind: true }, 32 | ); 33 | 34 | reaction( 35 | () => this.launchPoints, 36 | lps => this.launchPointsMap.replace(lps.map(lp => [lp.launchPointId, lp])), 37 | ); 38 | } 39 | 40 | public get fulfilled() { 41 | return this.providers.every(x => x.fulfilled); 42 | } 43 | 44 | public get launchPoints(): LaunchPointInstance[] { 45 | if (!this.fulfilled) { 46 | return []; 47 | } 48 | 49 | return this.providers.flatMap(x => x.launchPoints).map(this.resolveLaunchPoint); 50 | } 51 | 52 | public get visible() { 53 | // TODO 54 | return this.pickByIds([...this.order, '@intent:add_apps']); 55 | } 56 | 57 | public get hidden() { 58 | return this.pickByIds(this.hiddenIds); 59 | } 60 | 61 | public async launch({ appId, builtin, params }: LaunchPointInstance) { 62 | if (!builtin) { 63 | this.lifecycleManager.broadcastHide(); 64 | } 65 | 66 | return luna('luna://com.webos.service.applicationManager/launch', { id: appId, params }); 67 | } 68 | 69 | public show({ launchPointId }: LaunchPointInstance) { 70 | if (!this.order.includes(launchPointId)) { 71 | this.order = [...this.order, launchPointId]; 72 | } 73 | } 74 | 75 | public hide({ launchPointId }: LaunchPointInstance) { 76 | this.order = this.order.filter(x => x !== launchPointId); 77 | } 78 | 79 | public async uninstall(lp: LaunchPointInstance) { 80 | this.hide(lp); 81 | 82 | return luna('luna://com.webos.appInstallService/remove', { id: lp.appId }); 83 | } 84 | 85 | public move(lp: LaunchPointInstance, shift: number) { 86 | const from = this.visible.indexOf(lp); 87 | const to = from + shift; 88 | 89 | if (to < 0 || to > this.visible.length - 1) { 90 | return; 91 | } 92 | 93 | if (from !== to) { 94 | const ids = this.visible.map(x => x.launchPointId); 95 | 96 | ids.splice(from, 1); 97 | ids.splice(to, 0, lp.launchPointId); 98 | 99 | this.order = ids; 100 | } 101 | } 102 | 103 | private get order() { 104 | return this.settingsService.order.filter(x => !x.startsWith('@')); 105 | } 106 | 107 | private set order(value: string[]) { 108 | this.settingsService.order = value; 109 | } 110 | 111 | private get hiddenIds() { 112 | return keys(this.launchPointsMap as ObservableMap).filter( 113 | id => !this.order.includes(id) && !id.startsWith('@'), 114 | ); 115 | } 116 | 117 | private resolveLaunchPoint(snapshot: LaunchPointInput) { 118 | return ( 119 | this.launchPointsMap.get(snapshot.launchPointId)?.apply(snapshot) ?? 120 | this.launchPointFactory(snapshot) 121 | ); 122 | } 123 | 124 | private pickByIds(ids: string[]): LaunchPointInstance[] { 125 | return ids 126 | .map(id => this.launchPointsMap.get(id)) 127 | .filter((lp): lp is LaunchPointInstance => Boolean(lp)); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/app-manager/app-manager.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LunaMessage } from 'shared/services/luna'; 2 | 3 | import type { LaunchPointInput } from '../../api/launch-point.interface'; 4 | 5 | type LaunchPointChangeMixin = { 6 | change: 'added' | 'removed' | 'updated'; 7 | }; 8 | 9 | type LaunchPointsListMessage = { 10 | launchPoints: LaunchPointInput[]; 11 | }; 12 | 13 | type LaunchPointMutationMessage = LaunchPointInput & LaunchPointChangeMixin; 14 | 15 | export type AppManagerMessage = LunaMessage; 16 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/app-manager/app-manager.provider.ts: -------------------------------------------------------------------------------- 1 | import { comparer, makeAutoObservable, observable, reaction } from 'mobx'; 2 | 3 | import { injectable } from 'inversify'; 4 | 5 | import { LunaTopic } from 'shared/services/luna'; 6 | 7 | import type { LaunchPointInput } from '../../api/launch-point.interface'; 8 | import type { LaunchPointsProvider } from '../launch-points.provider'; 9 | 10 | import type { AppManagerMessage } from './app-manager.interface'; 11 | 12 | @injectable() 13 | export class AppManagerProvider implements LaunchPointsProvider { 14 | public launchPoints: LaunchPointInput[] = observable.array([], { equals: comparer.structural }); 15 | 16 | private topic = new LunaTopic( 17 | 'luna://com.webos.service.applicationManager/listLaunchPoints', 18 | ); 19 | 20 | public constructor() { 21 | makeAutoObservable(this, {}, { autoBind: true }); 22 | 23 | reaction(() => this.topic.message!, this.handleMessage); 24 | } 25 | 26 | public get fulfilled(): boolean { 27 | return Boolean(this.topic.message); 28 | } 29 | 30 | private handleMessage(message: AppManagerMessage): void { 31 | if (!message.returnValue) { 32 | return; 33 | } 34 | 35 | if ('launchPoints' in message) { 36 | this.launchPoints = message.launchPoints; 37 | 38 | return; 39 | } 40 | 41 | if (!('change' in message)) { 42 | return; 43 | } 44 | 45 | const { change } = message; 46 | 47 | if (change === 'added') { 48 | this.launchPoints.push(message); 49 | } 50 | 51 | const ref = this.launchPoints.find(x => x.id === message.id); 52 | 53 | if (!ref) { 54 | console.warn(`Unable to find referenced launch point: ${message.id}`); 55 | return; 56 | } 57 | 58 | if (change === 'updated') { 59 | Object.assign(ref, message); 60 | } 61 | 62 | if (change === 'removed') { 63 | this.launchPoints = this.launchPoints.filter(x => ref !== x); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/index.ts: -------------------------------------------------------------------------------- 1 | export { AppManagerProvider } from './app-manager/app-manager.provider'; 2 | export { InputProvider } from './input-manager/input-manager.provider'; 3 | export { InternalProvider } from './internal/internal.provider'; 4 | 5 | export { LaunchPointsProvider } from './launch-points.provider'; 6 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/input-manager/input-manager.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LunaMessage } from 'shared/services/luna'; 2 | 3 | export type Device = { 4 | label: string; 5 | appId: string; 6 | 7 | connected: boolean; 8 | activate: boolean; 9 | 10 | iconPrefix: string; 11 | icon: string; 12 | }; 13 | 14 | type InputStatusMessage = { 15 | devices: Device[]; 16 | }; 17 | 18 | export type InputManagerMessage = LunaMessage; 19 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/input-manager/input-manager.provider.ts: -------------------------------------------------------------------------------- 1 | import { computed, makeObservable } from 'mobx'; 2 | 3 | import { injectable } from 'inversify'; 4 | 5 | import { LunaTopic } from 'shared/services/luna'; 6 | 7 | import type { LaunchPointInput } from '../../api/launch-point.interface'; 8 | import type { LaunchPointsProvider } from '../launch-points.provider'; 9 | 10 | import type { Device, InputManagerMessage } from './input-manager.interface'; 11 | 12 | @injectable() 13 | export class InputProvider implements LaunchPointsProvider { 14 | private topic = new LunaTopic( 15 | 'luna://com.webos.service.eim/getAllInputStatus', 16 | ); 17 | 18 | public constructor() { 19 | makeObservable( 20 | this, 21 | { fulfilled: computed, launchPoints: computed.struct }, 22 | { autoBind: true }, 23 | ); 24 | } 25 | 26 | public get fulfilled(): boolean { 27 | return Boolean(this.topic.message); 28 | } 29 | 30 | public get launchPoints(): LaunchPointInput[] { 31 | const { message } = this.topic; 32 | 33 | if (!message?.returnValue) { 34 | return []; 35 | } 36 | 37 | return message.devices.map(this.mapDeviceToLaunchPoint); 38 | } 39 | 40 | private mapDeviceToLaunchPoint(device: Device): LaunchPointInput { 41 | return { 42 | id: device.appId, 43 | launchPointId: device.appId, 44 | title: device.label, 45 | icon: `./root${device.iconPrefix}${device.icon}`, 46 | iconColor: '#ffffff', 47 | removable: false, 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/internal/internal.provider.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | 3 | import { Intent } from 'shared/api/webos.d'; 4 | import type { ActivateType } from 'shared/api/webos.d'; 5 | 6 | import type { LaunchPointInput } from '../../api/launch-point.interface'; 7 | import type { LaunchPointsProvider } from '../launch-points.provider'; 8 | 9 | import plus from 'assets/plus.png'; 10 | 11 | @injectable() 12 | export class InternalProvider implements LaunchPointsProvider { 13 | public fulfilled = true; 14 | 15 | public launchPoints = [ 16 | { 17 | id: 'com.kitsuned.althome', 18 | launchPointId: '@intent:add_apps', 19 | title: 'Add apps', 20 | builtin: true, 21 | removable: false, 22 | iconColor: '#242424', 23 | icon: plus, 24 | params: { 25 | intent: Intent.AddApps, 26 | }, 27 | }, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/services/launcher/providers/launch-points.provider.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | 3 | import type { LaunchPointInput } from '../api/launch-point.interface'; 4 | 5 | @injectable() 6 | export abstract class LaunchPointsProvider { 7 | public abstract get fulfilled(): boolean; 8 | 9 | public abstract get launchPoints(): LaunchPointInput[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/services/lifecycle-manager/api/compositor.interface.ts: -------------------------------------------------------------------------------- 1 | export type LifecycleEventType = 2 | | 'splash' 3 | | 'launch' 4 | | 'foreground' 5 | | 'background' 6 | | 'stop' 7 | | 'close'; 8 | 9 | export type LifecycleEvent = { 10 | event: LifecycleEventType; 11 | appId: string; 12 | }; 13 | -------------------------------------------------------------------------------- /src/shared/services/lifecycle-manager/api/lifecycle-manager.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Intent } from 'shared/api/webos'; 2 | 3 | export type LifecycleManagerEvents = { 4 | relaunch: void; 5 | requestHide: void; 6 | intent: Intent; 7 | }; 8 | -------------------------------------------------------------------------------- /src/shared/services/lifecycle-manager/index.ts: -------------------------------------------------------------------------------- 1 | export { lifecycleManagerModule } from './lifecycle-manager.module'; 2 | 3 | export { LifecycleManagerService } from './service/lifecycle-manager.service'; 4 | -------------------------------------------------------------------------------- /src/shared/services/lifecycle-manager/lifecycle-manager.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { LifecycleManagerService } from './service/lifecycle-manager.service'; 4 | 5 | export const lifecycleManagerModule = new ContainerModule(bind => { 6 | bind(LifecycleManagerService).toSelf(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/services/lifecycle-manager/service/lifecycle-manager.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, reaction } from 'mobx'; 2 | 3 | import { inject, injectable } from 'inversify'; 4 | import mitt from 'mitt'; 5 | 6 | import type { ActivateType } from 'shared/api/webos.d'; 7 | import { luna, LunaTopic } from 'shared/services/luna'; 8 | import { SystemInfoService } from 'shared/services/system-info'; 9 | 10 | import type { LifecycleEvent } from '../api/compositor.interface'; 11 | import type { LifecycleManagerEvents } from '../api/lifecycle-manager.interface'; 12 | 13 | @injectable() 14 | export class LifecycleManagerService { 15 | public emitter = mitt(); 16 | 17 | private topic = new LunaTopic( 18 | 'luna://com.webos.service.applicationManager/getAppLifeEvents', 19 | ); 20 | 21 | private visible: boolean = true; 22 | 23 | public constructor( 24 | @inject(SystemInfoService) private readonly systemInfoService: SystemInfoService, 25 | ) { 26 | makeAutoObservable(this, {}, { autoBind: true }); 27 | 28 | reaction( 29 | () => this.topic.message, 30 | message => { 31 | if ( 32 | message?.appId !== process.env.APP_ID && 33 | (message?.event === 'splash' || message?.event === 'launch') 34 | ) { 35 | this.broadcastHide(); 36 | } 37 | }, 38 | ); 39 | 40 | document.addEventListener('webOSRelaunch', this.handleRelaunch); 41 | } 42 | 43 | public show() { 44 | this.visible = true; 45 | 46 | webOSSystem.activate(); 47 | } 48 | 49 | public hide() { 50 | this.visible = false; 51 | 52 | if (this.compositorShimsRequired) { 53 | this.requestSuspense(); 54 | } else { 55 | webOSSystem.hide(); 56 | } 57 | } 58 | 59 | public broadcastHide() { 60 | if (this.visible) { 61 | if (__DEV__) { 62 | console.log('broadcasting hide request'); 63 | } 64 | 65 | this.emitter.emit('requestHide'); 66 | } 67 | } 68 | 69 | private get compositorShimsRequired() { 70 | if (this.systemInfoService.osMajorVersion === 7) { 71 | return this.systemInfoService.osMinorVersion! < 3; 72 | } 73 | 74 | return this.systemInfoService.osMajorVersion 75 | ? this.systemInfoService?.osMajorVersion < 7 76 | : true; 77 | } 78 | 79 | private handleRelaunch(event: CustomEvent) { 80 | if (event.detail?.intent) { 81 | if (__DEV__) { 82 | console.log('broadcasting intent', event.detail); 83 | } 84 | 85 | this.emitter.emit('intent', event.detail.intent); 86 | } else if (event.detail?.activateType === 'home' && !this.visible) { 87 | this.emitter.emit('relaunch'); 88 | } else if (this.visible) { 89 | this.emitter.emit('requestHide'); 90 | } 91 | } 92 | 93 | private requestSuspense() { 94 | void luna('luna://com.webos.service.applicationManager/suspense', { 95 | id: process.env.APP_ID, 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/shared/services/luna/api/luna.api.ts: -------------------------------------------------------------------------------- 1 | export type LunaErrorMessage = { 2 | returnValue: false; 3 | errorCode?: number; 4 | errorText?: string; 5 | }; 6 | 7 | type DeepNever = { 8 | [K in keyof T]: T[K] extends Record ? DeepNever : never; 9 | }; 10 | 11 | export type LunaMessage = {}> = 12 | | (LunaErrorMessage & DeepNever) 13 | | (T & { returnValue: true }); 14 | 15 | export type LunaRequestParams = Record> = T & { 16 | subscribe?: boolean; 17 | }; 18 | -------------------------------------------------------------------------------- /src/shared/services/luna/index.ts: -------------------------------------------------------------------------------- 1 | export { luna, LunaTopic } from './model/luna.service'; 2 | 3 | export * from './api/luna.api'; 4 | -------------------------------------------------------------------------------- /src/shared/services/luna/lib/auto-elevator.lib.ts: -------------------------------------------------------------------------------- 1 | import type { LunaMessage } from '../api/luna.api'; 2 | import { luna } from '../model/luna.service'; 3 | 4 | export const requestElevation = async () => { 5 | const { root } = await luna<{ root: boolean }>( 6 | 'luna://org.webosbrew.hbchannel.service/getConfiguration', 7 | ); 8 | 9 | if (!root) { 10 | await luna('luna://com.webos.notification/createToast', { 11 | message: '[AltHome] Check root status!', 12 | }); 13 | 14 | return; 15 | } 16 | 17 | await luna('luna://com.webos.notification/createToast', { 18 | message: '[AltHome] Getting things ready…', 19 | }); 20 | 21 | await luna('luna://org.webosbrew.hbchannel.service/exec', { 22 | command: 23 | '/media/developer/apps/usr/palm/applications/com.kitsuned.althome/service --self-elevation', 24 | }); 25 | 26 | window.close(); 27 | }; 28 | 29 | export const verifyMessageContents = (message: LunaMessage) => { 30 | if (!message.returnValue && message.errorText?.startsWith('Denied method call')) { 31 | void requestElevation(); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/shared/services/luna/model/luna.service.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | // eslint-disable-next-line max-classes-per-file 3 | import { makeAutoObservable, reaction, toJS } from 'mobx'; 4 | 5 | import type { LunaMessage, LunaRequestParams } from '../api/luna.api'; 6 | import { verifyMessageContents } from '../lib/auto-elevator.lib'; 7 | 8 | export class LunaTopic, P extends LunaRequestParams = {}> { 9 | public message: LunaMessage | null = null; 10 | 11 | private bridge!: PalmServiceBridge; 12 | 13 | public constructor(private readonly uri: string, private readonly params?: P) { 14 | makeAutoObservable, 'bridge'>(this, { bridge: false }, { autoBind: true }); 15 | 16 | this.subscribe(); 17 | 18 | if (__DEV__) { 19 | console.log('', uri); 20 | 21 | reaction( 22 | () => this.message, 23 | message => console.log('<*-', uri, toJS(message)), 24 | ); 25 | } 26 | } 27 | 28 | private subscribe() { 29 | this.bridge = new PalmServiceBridge(); 30 | 31 | this.bridge.onservicecallback = this.handleCallback; 32 | 33 | this.bridge.call(this.uri, JSON.stringify(this.params ?? { subscribe: true })); 34 | } 35 | 36 | private handleCallback(serialized: string) { 37 | this.message = JSON.parse(serialized); 38 | 39 | if (this.message) { 40 | verifyMessageContents(this.message); 41 | } 42 | } 43 | } 44 | 45 | class LunaOneShot, P extends LunaRequestParams = {}> { 46 | private readonly bridge: PalmServiceBridge = new PalmServiceBridge(); 47 | 48 | public constructor(public readonly uri: string, public readonly params?: P) {} 49 | 50 | public call() { 51 | return new Promise((resolve, reject) => { 52 | this.bridge.onservicecallback = (message: string) => { 53 | const parsed = JSON.parse(message); 54 | 55 | if (__DEV__) { 56 | console.log('<--', this.uri, parsed); 57 | } 58 | 59 | if (parsed.errorCode || !parsed.returnValue) { 60 | verifyMessageContents(parsed); 61 | 62 | reject(parsed); 63 | } 64 | 65 | resolve(parsed); 66 | }; 67 | 68 | if (__DEV__) { 69 | console.log('-->', this.uri, this.params); 70 | } 71 | 72 | this.bridge.call(this.uri, JSON.stringify(this.params ?? {})); 73 | }); 74 | } 75 | } 76 | 77 | export const luna = , P extends LunaRequestParams = {}>( 78 | uri: string, 79 | params?: P, 80 | ) => new LunaOneShot(uri, params).call(); 81 | -------------------------------------------------------------------------------- /src/shared/services/services.init.ts: -------------------------------------------------------------------------------- 1 | import { container } from '@di'; 2 | 3 | import { launcherModule } from './launcher'; 4 | import { lifecycleManagerModule } from './lifecycle-manager'; 5 | import { settingsModule } from './settings'; 6 | import { systemInfoModule } from './system-info'; 7 | 8 | container.load(systemInfoModule, settingsModule, lifecycleManagerModule, launcherModule); 9 | -------------------------------------------------------------------------------- /src/shared/services/settings/index.ts: -------------------------------------------------------------------------------- 1 | export { settingsModule } from './settings.module'; 2 | 3 | export { SettingsService } from './model/settings.service'; 4 | -------------------------------------------------------------------------------- /src/shared/services/settings/model/settings.service.ts: -------------------------------------------------------------------------------- 1 | import { comparer, makeAutoObservable, reaction, toJS, when } from 'mobx'; 2 | 3 | import { injectable } from 'inversify'; 4 | 5 | import { throttle } from 'shared/core/utils/throttle'; 6 | import { luna, LunaTopic } from 'shared/services/luna'; 7 | 8 | const KEY = process.env.APP_ID as 'com.kitsuned.althome'; 9 | 10 | type ConfigMessage = { 11 | configs: { 12 | [KEY]: Settings; 13 | }; 14 | missingConfigs: string[]; 15 | }; 16 | 17 | type Settings = Omit; 18 | 19 | @injectable() 20 | export class SettingsService { 21 | public hydrated: boolean = false; 22 | 23 | public memoryQuirks: boolean = true; 24 | public wheelVelocityFactor: number = 1.5; 25 | public addNewApps: boolean = true; 26 | public order: string[] = []; 27 | 28 | private topic = new LunaTopic('luna://com.webos.service.config/getConfigs', { 29 | configNames: [KEY], 30 | subscribe: true, 31 | }); 32 | 33 | public constructor() { 34 | this.saveConfig = throttle(this.saveConfig, 5 * 1000); 35 | 36 | makeAutoObservable(this, {}, { autoBind: true }); 37 | 38 | reaction( 39 | () => this.topic.message?.configs?.[KEY], 40 | settings => this.hydrate(settings ?? {}), 41 | ); 42 | 43 | when( 44 | () => Boolean(this.topic.message?.missingConfigs), 45 | () => this.hydrate({}), 46 | ); 47 | 48 | when( 49 | () => this.hydrated, 50 | () => reaction(() => this.serialized, this.saveConfig, { equals: comparer.structural }), 51 | ); 52 | } 53 | 54 | private saveConfig(serialized: Settings) { 55 | void luna('luna://com.webos.service.config/setConfigs', { 56 | configs: { 57 | [KEY]: serialized, 58 | }, 59 | }); 60 | } 61 | 62 | private get serialized(): Settings { 63 | const { topic, hydrated, ...settings } = toJS(this); 64 | 65 | return settings; 66 | } 67 | 68 | private hydrate(json: Partial) { 69 | this.hydrated = true; 70 | 71 | Object.assign(this, json); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/shared/services/settings/settings.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { SettingsService } from './model/settings.service'; 4 | 5 | export const settingsModule = new ContainerModule(bind => { 6 | bind(SettingsService).toSelf(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/shared/services/system-info/api/system-info.interface.ts: -------------------------------------------------------------------------------- 1 | import type { LunaMessage } from '../../luna'; 2 | import type { systemInfoKeys } from '../lib/system-info-keys.lib'; 3 | 4 | export type SystemInfo = Record<(typeof systemInfoKeys)[number], string>; 5 | 6 | export type SystemInfoMessage = LunaMessage; 7 | -------------------------------------------------------------------------------- /src/shared/services/system-info/index.ts: -------------------------------------------------------------------------------- 1 | export { systemInfoModule } from './system-info.module'; 2 | 3 | export { SystemInfoService } from './model/system-info.service'; 4 | -------------------------------------------------------------------------------- /src/shared/services/system-info/lib/system-info-keys.lib.ts: -------------------------------------------------------------------------------- 1 | export const systemInfoKeys = ['firmwareVersion', 'sdkVersion', 'modelName'] as const; 2 | -------------------------------------------------------------------------------- /src/shared/services/system-info/model/system-info.service.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, runInAction } from 'mobx'; 2 | 3 | import { injectable } from 'inversify'; 4 | 5 | import { luna } from '../../luna'; 6 | import type { SystemInfoMessage } from '../api/system-info.interface'; 7 | import { systemInfoKeys } from '../lib/system-info-keys.lib'; 8 | 9 | @injectable() 10 | export class SystemInfoService { 11 | public firmwareVersion: string | null = null; 12 | public modelName: string | null = null; 13 | public sdkVersion: string | null = null; 14 | 15 | public constructor() { 16 | makeAutoObservable(this, {}, { autoBind: true }); 17 | 18 | void luna('luna://com.webos.service.tv.systemproperty/getSystemInfo', { 19 | keys: systemInfoKeys, 20 | }).then(({ returnValue, ...rest }) => { 21 | if (returnValue) { 22 | runInAction(() => Object.assign(this, rest)); 23 | } 24 | }); 25 | } 26 | 27 | public get osMajorVersion(): number | null { 28 | return this.osVersionParts ? this.osVersionParts[0] : null; 29 | } 30 | 31 | public get osMinorVersion(): number | null { 32 | return this.osVersionParts ? this.osVersionParts[1] : null; 33 | } 34 | 35 | private get osVersionParts(): [number, number, number] | null { 36 | return this.sdkVersion 37 | ? (this.sdkVersion.split('.').map(x => Number(x)) as [number, number, number]) 38 | : null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/services/system-info/system-info.module.ts: -------------------------------------------------------------------------------- 1 | import { ContainerModule } from 'inversify'; 2 | 3 | import { SystemInfoService } from './model/system-info.service'; 4 | 5 | export const systemInfoModule = new ContainerModule(bind => { 6 | bind(SystemInfoService).toSelf(); 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ES2020", 6 | "DOM" 7 | ], 8 | "types": [ 9 | "reflect-metadata" 10 | ], 11 | "module": "ES2020", 12 | "moduleResolution": "Node", 13 | "jsx": "react-jsx", 14 | "baseUrl": "./src", 15 | "allowSyntheticDefaultImports": true, 16 | "allowUmdGlobalAccess": true, 17 | "declaration": false, 18 | "esModuleInterop": false, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "resolveJsonModule": true, 22 | "skipLibCheck": true, 23 | "strict": true, 24 | "useDefineForClassFields": true, 25 | "experimentalDecorators": true, 26 | "paths": { 27 | "@di": ["shared/core/di"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /webpack.app.ts: -------------------------------------------------------------------------------- 1 | import { ProvidePlugin, DefinePlugin } from 'webpack'; 2 | 3 | import CopyPlugin from 'copy-webpack-plugin'; 4 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 5 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 6 | import TSConfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; 7 | 8 | import { JsonTransformer } from 'webpack-utils'; 9 | import type { WebpackConfigFunction } from 'webpack-utils'; 10 | 11 | import { id, version } from './package.json'; 12 | 13 | const transformer = new JsonTransformer({ 14 | APP_ID: id, 15 | APP_VERSION: version, 16 | }); 17 | 18 | const config: WebpackConfigFunction<{ WEBPACK_SERVE?: boolean }> = (_, argv) => ({ 19 | id, 20 | name: 'app', 21 | target: 'web', 22 | mode: argv.mode ?? 'development', 23 | entry: './src/index.tsx', 24 | devtool: 'source-map', 25 | devServer: { 26 | hot: true, 27 | }, 28 | output: { 29 | filename: 'app.js', 30 | }, 31 | resolve: { 32 | extensions: [...(argv.mode !== 'development' ? [] : ['.dev.ts']), '.js', '.ts', '.tsx'], 33 | plugins: [new TSConfigPathsPlugin()], 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.[mc]?[jt]sx?$/, 39 | exclude: [/node_modules\/core-js/], 40 | use: { 41 | loader: 'babel-loader', 42 | options: { 43 | sourceType: 'unambiguous', 44 | presets: [ 45 | [ 46 | '@babel/env', 47 | { 48 | useBuiltIns: 'usage', 49 | corejs: '3.37', 50 | targets: { chrome: 79 }, // corresponds to webOS 6 51 | }, 52 | ], 53 | ['@babel/react'], 54 | ['@babel/typescript', { onlyRemoveTypeImports: true }], 55 | ], 56 | plugins: [ 57 | ['transform-typescript-metadata'], 58 | ['@babel/plugin-proposal-decorators', { version: 'legacy' }], 59 | ['@babel/plugin-transform-class-properties'], 60 | ], 61 | }, 62 | }, 63 | }, 64 | { 65 | test: /.scss$/, 66 | use: [ 67 | MiniCssExtractPlugin.loader, 68 | 'css-loader', 69 | 'sass-loader', 70 | { 71 | loader: 'postcss-loader', 72 | options: { 73 | postcssOptions: { 74 | plugins: ['autoprefixer', 'postcss-preset-env'], 75 | }, 76 | }, 77 | }, 78 | ], 79 | }, 80 | { 81 | test: /.png$/, 82 | type: 'asset/resource', 83 | generator: { 84 | filename: 'assets/[hash][ext]', 85 | }, 86 | }, 87 | ], 88 | }, 89 | performance: { 90 | hints: false, 91 | }, 92 | plugins: [ 93 | new ProvidePlugin({ 94 | React: 'react', 95 | }), 96 | new DefinePlugin({ 97 | __DEV__: JSON.stringify(argv.mode === 'development'), 98 | 'process.env.APP_ID': JSON.stringify(id), 99 | }), 100 | new MiniCssExtractPlugin(), 101 | new HtmlWebpackPlugin({ 102 | template: './src/app/index.html', 103 | }), 104 | new CopyPlugin({ 105 | patterns: [ 106 | { 107 | from: '**/*', 108 | context: 'manifests/app', 109 | priority: 10, 110 | }, 111 | { 112 | from: '**/*.json', 113 | context: 'manifests/app', 114 | transform: transformer.transform, 115 | priority: 0, 116 | }, 117 | { 118 | from: 'agentd*', 119 | context: 'agent', 120 | to: 'service', 121 | toType: 'file', 122 | }, 123 | ], 124 | }), 125 | ], 126 | }); 127 | 128 | export default config; 129 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { hoc } from '@webosbrew/webos-packager-plugin'; 2 | 3 | import { id, version } from './package.json'; 4 | import app from './webpack.app'; 5 | import service from './webpack.service'; 6 | 7 | export default hoc({ 8 | id, 9 | version, 10 | app, 11 | services: [service], 12 | }); 13 | -------------------------------------------------------------------------------- /webpack.service.ts: -------------------------------------------------------------------------------- 1 | import { DefinePlugin } from 'webpack'; 2 | 3 | import CopyPlugin from 'copy-webpack-plugin'; 4 | 5 | import { JsonTransformer } from 'webpack-utils'; 6 | import type { WebpackConfigFunction } from 'webpack-utils'; 7 | 8 | import { id, version } from './package.json'; 9 | 10 | const SERVICE_ID = `${id}.service`; 11 | 12 | const transformer = new JsonTransformer({ 13 | APP_ID: id, 14 | APP_VERSION: version, 15 | SERVICE_ID, 16 | }); 17 | 18 | const config: WebpackConfigFunction<{ WEBPACK_SERVE?: boolean }> = (_, argv) => ({ 19 | id: SERVICE_ID, 20 | name: 'service', 21 | mode: argv.mode ?? 'development', 22 | target: 'node8.12', 23 | entry: './service/index.ts', 24 | output: { 25 | filename: 'service.js', 26 | }, 27 | externals: { 28 | palmbus: 'commonjs palmbus', 29 | }, 30 | resolve: { 31 | extensions: ['.ts', '.js'], 32 | }, 33 | module: { 34 | rules: [ 35 | // TODO: move to Babel 36 | { 37 | test: /.[jt]sx?$/, 38 | loader: 'babel-loader', 39 | options: { 40 | presets: [ 41 | ['@babel/env', { targets: { node: 12 } }], 42 | ['@babel/typescript', { onlyRemoveTypeImports: true }], 43 | ], 44 | }, 45 | }, 46 | { 47 | test: /.source.\w+$/, 48 | type: 'asset/source', 49 | loader: 'babel-loader', 50 | options: { 51 | presets: [ 52 | [ 53 | '@babel/env', 54 | { 55 | // surface manager uses a custom JS engine: QT V4 56 | // let's set target to something ancient just to cover ES3 57 | targets: { chrome: 4 }, 58 | }, 59 | ], 60 | ], 61 | plugins: [ 62 | [ 63 | 'minify-replace', 64 | { 65 | replacements: [ 66 | { 67 | identifierName: '__APP_ID__', 68 | replacement: { 69 | type: 'stringLiteral', 70 | value: id, 71 | }, 72 | }, 73 | ], 74 | }, 75 | ], 76 | ], 77 | }, 78 | }, 79 | ], 80 | }, 81 | plugins: [ 82 | new DefinePlugin({ 83 | __DEV__: JSON.stringify(argv.mode === 'development'), 84 | 'process.env.APP_ID': JSON.stringify(id), 85 | 'process.env.SERVICE_ID': JSON.stringify(SERVICE_ID), 86 | }), 87 | new CopyPlugin({ 88 | patterns: [ 89 | { 90 | from: '*.json', 91 | context: 'manifests/service', 92 | transform: transformer.transform, 93 | }, 94 | ], 95 | }), 96 | ], 97 | }); 98 | 99 | export default config; 100 | --------------------------------------------------------------------------------