├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── resources ├── dbus │ └── io.elhan.ExtensionsSync.xml ├── icons │ ├── extensions-sync-download-symbolic.svg │ ├── extensions-sync-preferences-symbolic.svg │ ├── extensions-sync-synced-symbolic.svg │ ├── extensions-sync-syncing-symbolic.svg │ └── extensions-sync-upload-symbolic.svg ├── metadata.json └── schemas │ └── org.gnome.shell.extensions.extensions-sync.gschema.xml ├── rollup.config.js ├── src ├── api │ ├── index.ts │ ├── providers │ │ ├── github.ts │ │ ├── gitlab.ts │ │ └── local.ts │ └── types.ts ├── data │ ├── index.ts │ └── providers │ │ ├── extensions │ │ ├── provider.ts │ │ └── utils.ts │ │ ├── keybindings.ts │ │ └── tweaks.ts ├── extension.ts ├── index.d.ts ├── panel │ └── statusMenu.ts ├── prefs │ ├── otherPrefs.ts │ ├── prefs.ts │ ├── prefsTab.ts │ ├── providerPrefs.ts │ └── syncedDataPrefs.ts ├── shell │ └── index.ts ├── styles │ └── stylesheet.css ├── sync │ └── index.ts └── utils │ └── index.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false 9 | 10 | [*.{js,ts}] 11 | quote_type = single 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules/ 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | lib/ 5 | tmp/ 6 | test/*.js 7 | # don't lint templates 8 | templates/ 9 | # temporary ignore examples 10 | # examples/ 11 | # temporary ignore generated types, remove this if the heap out of memory bug is fixed 12 | @types/ 13 | *.js 14 | */*.js 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // see https://www.robertcooper.me/using-eslint-and-prettier-in-a-typescript-project 2 | module.exports = { 3 | root: true, 4 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 5 | extends: [ 6 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 8 | ], 9 | rules: { 10 | 'quotes': [2, 'single', { 'avoidEscape': true }], 11 | 'no-debugger': 'off', 12 | '@typescript-eslint/no-explicit-any': 'off', 13 | '@typescript-eslint/no-misused-new': 'off', 14 | '@typescript-eslint/triple-slash-reference': 'off', 15 | '@typescript-eslint/no-unused-vars': 'error', 16 | // For Gjs 17 | 'camelcase': 'off', 18 | '@typescript-eslint/camelcase': 'off' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: oae 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,linux,webstorm,sublimetext,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=node,linux,webstorm,sublimetext,visualstudiocode 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### Node ### 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | lerna-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | *.lcov 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # Compiled binary addons (https://nodejs.org/api/addons.html) 58 | build/Release 59 | 60 | # Dependency directories 61 | node_modules/ 62 | jspm_packages/ 63 | 64 | # TypeScript v1 declaration files 65 | typings/ 66 | 67 | # TypeScript cache 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | .npm 72 | 73 | # Optional eslint cache 74 | .eslintcache 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variables file 86 | .env 87 | .env.test 88 | 89 | # parcel-bundler cache (https://parceljs.org/) 90 | .cache 91 | 92 | # next.js build output 93 | .next 94 | 95 | # nuxt.js build output 96 | .nuxt 97 | 98 | # rollup.js default build output 99 | dist/ 100 | 101 | # Uncomment the public line if your project uses Gatsby 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 104 | # public 105 | 106 | # Storybook build outputs 107 | .out 108 | .storybook-out 109 | 110 | # vuepress build output 111 | .vuepress/dist 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # Temporary folders 123 | tmp/ 124 | temp/ 125 | 126 | ### SublimeText ### 127 | # Cache files for Sublime Text 128 | *.tmlanguage.cache 129 | *.tmPreferences.cache 130 | *.stTheme.cache 131 | 132 | # Workspace files are user-specific 133 | *.sublime-workspace 134 | 135 | # Project files should be checked into the repository, unless a significant 136 | # proportion of contributors will probably not be using Sublime Text 137 | # *.sublime-project 138 | 139 | # SFTP configuration file 140 | sftp-config.json 141 | 142 | # Package control specific files 143 | Package Control.last-run 144 | Package Control.ca-list 145 | Package Control.ca-bundle 146 | Package Control.system-ca-bundle 147 | Package Control.cache/ 148 | Package Control.ca-certs/ 149 | Package Control.merged-ca-bundle 150 | Package Control.user-ca-bundle 151 | oscrypto-ca-bundle.crt 152 | bh_unicode_properties.cache 153 | 154 | # Sublime-github package stores a github token in this file 155 | # https://packagecontrol.io/packages/sublime-github 156 | GitHub.sublime-settings 157 | 158 | ### Vim ### 159 | # Swap 160 | [._]*.s[a-v][a-z] 161 | [._]*.sw[a-p] 162 | [._]s[a-rt-v][a-z] 163 | [._]ss[a-gi-z] 164 | [._]sw[a-p] 165 | 166 | # Session 167 | Session.vim 168 | 169 | # Temporary 170 | .netrwhist 171 | # Auto-generated tag files 172 | tags 173 | # Persistent undo 174 | [._]*.un~ 175 | 176 | ### VisualStudioCode ### 177 | .vscode/* 178 | !.vscode/settings.json 179 | !.vscode/tasks.json 180 | !.vscode/launch.json 181 | !.vscode/extensions.json 182 | 183 | 184 | # End of https://www.gitignore.io/api/vim,linux,sublimetext,visualstudiocode 185 | gschemas.compiled 186 | extensions-sync@elhan.io*.zip 187 | _build 188 | node_modules 189 | @types 190 | dist 191 | ### VisualStudioCode Patch ### 192 | # Ignore all local history of files 193 | .history 194 | 195 | ### WebStorm ### 196 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 197 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 198 | 199 | # User-specific stuff 200 | .idea/**/workspace.xml 201 | .idea/**/tasks.xml 202 | .idea/**/usage.statistics.xml 203 | .idea/**/dictionaries 204 | .idea/**/shelf 205 | 206 | # Generated files 207 | .idea/**/contentModel.xml 208 | 209 | # Sensitive or high-churn files 210 | .idea/**/dataSources/ 211 | .idea/**/dataSources.ids 212 | .idea/**/dataSources.local.xml 213 | .idea/**/sqlDataSources.xml 214 | .idea/**/dynamic.xml 215 | .idea/**/uiDesigner.xml 216 | .idea/**/dbnavigator.xml 217 | 218 | # Gradle 219 | .idea/**/gradle.xml 220 | .idea/**/libraries 221 | 222 | # Gradle and Maven with auto-import 223 | # When using Gradle or Maven with auto-import, you should exclude module files, 224 | # since they will be recreated, and may cause churn. Uncomment if using 225 | # auto-import. 226 | # .idea/modules.xml 227 | # .idea/*.iml 228 | # .idea/modules 229 | # *.iml 230 | # *.ipr 231 | 232 | # CMake 233 | cmake-build-*/ 234 | 235 | # Mongo Explorer plugin 236 | .idea/**/mongoSettings.xml 237 | 238 | # File-based project format 239 | *.iws 240 | 241 | # IntelliJ 242 | out/ 243 | 244 | # mpeltonen/sbt-idea plugin 245 | .idea_modules/ 246 | 247 | # JIRA plugin 248 | atlassian-ide-plugin.xml 249 | 250 | # Cursive Clojure plugin 251 | .idea/replstate.xml 252 | 253 | # Crashlytics plugin (for Android Studio and IntelliJ) 254 | com_crashlytics_export_strings.xml 255 | crashlytics.properties 256 | crashlytics-build.properties 257 | fabric.properties 258 | 259 | # Editor-based Rest Client 260 | .idea/httpRequests 261 | 262 | # Android studio 3.1+ serialized cache file 263 | .idea/caches/build_file_checksums.ser 264 | 265 | ### WebStorm Patch ### 266 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 267 | 268 | # *.iml 269 | # modules.xml 270 | # .idea/misc.xml 271 | # *.ipr 272 | 273 | # Sonarlint plugin 274 | .idea/**/sonarlint/ 275 | 276 | # SonarQube Plugin 277 | .idea/**/sonarIssues.xml 278 | 279 | # Markdown Navigator plugin 280 | .idea/**/markdown-navigator.xml 281 | .idea/**/markdown-navigator/ 282 | 283 | ### Vagrant ### 284 | # General 285 | .vagrant/ 286 | 287 | # Log files (if you are creating logs in debug mode, uncomment this) 288 | # *.log 289 | 290 | ### Vagrant Patch ### 291 | *.box 292 | 293 | # End of https://www.gitignore.io/api/node,linux,webstorm,sublimetext,visualstudiocode 294 | 295 | # ts-for-gjs output 296 | 297 | docs-lock.json -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#1e593f", 4 | "activityBar.background": "#1e593f", 5 | "activityBar.foreground": "#e7e7e7", 6 | "activityBar.inactiveForeground": "#e7e7e799", 7 | "activityBarBadge.background": "#221030", 8 | "activityBarBadge.foreground": "#e7e7e7", 9 | "commandCenter.border": "#e7e7e799", 10 | "sash.hoverBorder": "#1e593f", 11 | "statusBar.background": "#113324", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#1e593f", 14 | "statusBarItem.remoteBackground": "#113324", 15 | "statusBarItem.remoteForeground": "#e7e7e7", 16 | "titleBar.activeBackground": "#113324", 17 | "titleBar.activeForeground": "#e7e7e7", 18 | "titleBar.inactiveBackground": "#11332499", 19 | "titleBar.inactiveForeground": "#e7e7e799" 20 | }, 21 | "peacock.color": "#113324" 22 | } -------------------------------------------------------------------------------- /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 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w". 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c" for details. 319 | 320 | The hypothetical commands `show w" and `show c" should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w" and `show c"; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision" (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extensions Sync 2 | 3 | [![ts](https://badgen.net/badge/icon/typescript?icon=typescript&label)](#) 4 | [![opensource](https://badges.frapsoft.com/os/v1/open-source.png?v=103)](#) 5 | [![licence](https://badges.frapsoft.com/os/gpl/gpl.png?v=103)](https://github.com/oae/gnome-shell-extensions-sync/blob/master/LICENSE) 6 | [![latest](https://img.shields.io/github/v/release/oae/gnome-shell-extensions-sync)](https://github.com/oae/gnome-shell-extensions-sync/releases/latest) 7 | [![compare](https://img.shields.io/github/commits-since/oae/gnome-shell-extensions-sync/latest/master)](https://github.com/oae/gnome-shell-extensions-sync/compare) 8 | 9 | Syncs gnome shell keybindings, tweaks settings and extensions with their configuration across all gnome installations 10 | 11 | | Provider | Synced Data | Other Settings | 12 | |:------------------------------------:|:------------------------------------:|:------------------------------------:| 13 | | ![](https://i.imgur.com/4Sv3Jus.png) | ![](https://i.imgur.com/Ii6Q8w3.png) | ![](https://i.imgur.com/OvDy80f.png) | 14 | 15 | ## Installation 16 | 17 | ### From [Git](https://github.com/oae/gnome-shell-extensions-sync) 18 | 19 | ```bash 20 | git clone https://github.com/oae/gnome-shell-extensions-sync.git 21 | cd ./gnome-shell-extensions-sync 22 | yarn install 23 | yarn build 24 | ln -s "$PWD/dist" "$HOME/.local/share/gnome-shell/extensions/extensions-sync@elhan.io" 25 | ``` 26 | 27 | ### From [Ego](https://extensions.gnome.org) 28 | 29 | You can install it from [**here**](https://extensions.gnome.org/extension/1486/extensions-sync/) 30 | 31 | 32 | ## Usage 33 | 34 | - You can select the data types that are going to be saved in the settings. 35 | 36 | ## For Github 37 | 38 | 1. Create a new gist from [here](https://gist.github.com/) I suggest you make it secret. You will need the gist id for this. You can find it in the url after username. For example on gist url `https://gist.github.com/username/f545156c0083f7eaefa44ab69df4ec37`, gist id will be `f545156c0083f7eaefa44ab69df4ec37`. [Guide](https://docs.github.com/en/get-started/writing-on-github/editing-and-sharing-content-with-gists/creating-gists) 39 | 2. Create a new token from [here](https://github.com/settings/tokens/new). Only **gist permission** is needed since we edit the gists. [Guide](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 40 | 3. Open extension settings, select the `Github` provider and fill gist id from first step and user token from second step. 41 | 42 | ## For Gitlab 43 | 44 | 1. Create a new snippet from [here](https://gitlab.com/-/snippets/new) I suggest you make it private. You will need the snippet id for this. You can find it in the url. For example on snippet url `https://gitlab.com/-/snippets/324234234`, snippet id will be `324234234`. [Guide](https://docs.gitlab.com/ee/user/snippets.html#create-snippets) 45 | 2. Create a new token from [here](https://gitlab.com/-/profile/personal_access_tokens). Only **api scope** is needed. [Guide](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token) 46 | 3. Open extension settings, select the `Gitlab` provider and fill snippet id from first step and user token from second step. 47 | 48 | ## For Local 49 | 50 | 1. Select a file that has read/write permission by your active user. (default backup file is in `~/.config/extensions-sync.json`) 51 | 52 | ## Cli Usage 53 | 54 | You can trigger upload download operations using busctl. 55 | 56 | ```sh 57 | busctl --user call org.gnome.Shell /io/elhan/ExtensionsSync io.elhan.ExtensionsSync save # uploads to server 58 | busctl --user call org.gnome.Shell /io/elhan/ExtensionsSync io.elhan.ExtensionsSync read # downloads to pc 59 | ``` 60 | 61 | ## Development 62 | 63 | - This extension is written in Typescript and uses rollup to compile it into javascript. 64 | - To start development, you need nodejs installed on your system; 65 | 66 | - Clone the project 67 | 68 | ```sh 69 | git clone https://github.com/oae/gnome-shell-extensions-sync.git 70 | cd ./gnome-shell-extensions-sync 71 | ``` 72 | 73 | - Install dependencies and build it 74 | 75 | ```sh 76 | yarn install 77 | yarn build 78 | ln -s "$PWD/dist" "$HOME/.local/share/gnome-shell/extensions/extensions-sync@elhan.io" 79 | ``` 80 | 81 | - During development you can use `yarn watch` command to keep generated code up-to-date. 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extensions-sync", 3 | "version": "1.0.0", 4 | "author": "Alperen Elhan ", 5 | "private": true, 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "yarn run build:ts && yarn run build:extension", 9 | "clean": "yarn run clean:ts", 10 | "build:ts": "yarn run clean:ts && rollup -c", 11 | "clean:ts": "rm -rf ./dist", 12 | "build:extension": "yarn run build:schema", 13 | "build:schema": "yarn run clean:schema && glib-compile-schemas ./resources/schemas --targetdir=./dist/schemas/", 14 | "clean:schema": "rm -rf ./dist/schemas/*.compiled", 15 | "build:package": "yarn run build && rm -rf './dist/extensions-sync@elhan.io.zip' && cd ./dist && zip -qr 'extensions-sync@elhan.io.zip' .", 16 | "watch": "yarn run build && yarn run rollup -c --watch", 17 | "test": "echo \"Error: no test specified\" && exit 1", 18 | "lint": "eslint --ext .ts src/" 19 | }, 20 | "commitlint": { 21 | "extends": [ 22 | "@commitlint/config-conventional" 23 | ] 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "yarn run lint", 28 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 29 | } 30 | }, 31 | "devDependencies": { 32 | "@commitlint/cli": "^13.2.1", 33 | "@commitlint/config-conventional": "^13.2.0", 34 | "@gi-types/gdk4": "^4.0.1", 35 | "@gi-types/gdkpixbuf2": "^2.0.2", 36 | "@gi-types/gio2": "^2.72.1", 37 | "@gi-types/glib2": "^2.72.1", 38 | "@gi-types/gobject2": "^2.72.1", 39 | "@gi-types/gtk4": "^4.6.1", 40 | "@gi-types/meta10": "^10.0.1", 41 | "@gi-types/shell0": "^0.1.1", 42 | "@gi-types/soup3": "^3.0.1", 43 | "@gi-types/st1": "^1.0.1", 44 | "@gi.ts/cli": "^1.5.7", 45 | "@gi.ts/lib": "^1.5.9", 46 | "@gi.ts/parser": "^1.5.3", 47 | "@rollup/plugin-commonjs": "^21.0.1", 48 | "@rollup/plugin-node-resolve": "^13.0.6", 49 | "@rollup/plugin-typescript": "^8.3.0", 50 | "@types/events": "^3.0.0", 51 | "@types/xml2js": "^0.4.9", 52 | "@typescript-eslint/eslint-plugin": "^5.2.0", 53 | "@typescript-eslint/parser": "^5.2.0", 54 | "cross-env": "^7.0.3", 55 | "eslint": "^8.1.0", 56 | "eslint-config-prettier": "^8.3.0", 57 | "eslint-plugin-prettier": "^4.0.0", 58 | "husky": "^4.3.8", 59 | "prettier": "^2.4.1", 60 | "rollup": "^2.58.3", 61 | "rollup-plugin-cleanup": "^3.2.1", 62 | "rollup-plugin-copy": "^3.4.0", 63 | "rollup-plugin-styles": "^3.14.1", 64 | "typescript": "^4.4.4", 65 | "xml2js": "^0.4.23" 66 | }, 67 | "dependencies": { 68 | "events": "^3.3.0", 69 | "fast-xml-parser": "^3.21.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /resources/dbus/io.elhan.ExtensionsSync.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/icons/extensions-sync-download-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/icons/extensions-sync-preferences-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/extensions-sync-synced-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/extensions-sync-syncing-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/extensions-sync-upload-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Extensions Sync", 3 | "description": "Sync all extensions and their configurations across all gnome instances", 4 | "uuid": "extensions-sync@elhan.io", 5 | "version": 999, 6 | "donations": { 7 | "github": "oae" 8 | }, 9 | "settings-schema": "org.gnome.shell.extensions.extensions-sync", 10 | "url": "https://github.com/oae/gnome-shell-extensions-sync", 11 | "shell-version": ["42", "43", "44"] 12 | } 13 | -------------------------------------------------------------------------------- /resources/schemas/org.gnome.shell.extensions.extensions-sync.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | "" 18 | Gist Id 19 | You can create a gist from https://gist.github.com/ and use it's id 20 | 21 | 22 | "" 23 | Github token 24 | Generate a new token from https://github.com/settings/tokens/new with gist permission and use it 25 | 26 | 27 | "" 28 | Snippet Id 29 | Snippet Id 30 | 31 | 32 | "" 33 | Gitlab token 34 | Gitlab token 35 | 36 | 37 | "" 38 | Backup file location 39 | Backup file location 40 | 41 | 42 | 'Github' 43 | Provider for synchronization. 44 | Provider for synchronization. 45 | 46 | 47 | ['extensions','keybindings','tweaks'] 48 | Data Providers that are going to be used. 49 | Data Providers that are going to be used. 50 | 51 | 52 | true 53 | Show Tray Icon 54 | Controls the visibility of the tray icon. 55 | 56 | 57 | true 58 | Show Notifications 59 | Controls the visibility of the notifications. 60 | 61 | 62 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import styles from 'rollup-plugin-styles'; 5 | import copy from 'rollup-plugin-copy'; 6 | import cleanup from 'rollup-plugin-cleanup'; 7 | 8 | const buildPath = 'dist'; 9 | 10 | const globals = { 11 | '@gi-types/gio2': 'imports.gi.Gio', 12 | '@gi-types/gdk4': 'imports.gi.Gdk', 13 | '@gi-types/gtk4': 'imports.gi.Gtk', 14 | '@gi-types/gdkpixbuf2': 'imports.gi.GdkPixbuf', 15 | '@gi-types/glib2': 'imports.gi.GLib', 16 | '@gi-types/st1': 'imports.gi.St', 17 | '@gi-types/shell0': 'imports.gi.Shell', 18 | '@gi-types/meta10': 'imports.gi.Meta', 19 | '@gi-types/soup3': 'imports.gi.Soup', 20 | '@gi-types/gobject2': 'imports.gi.GObject', 21 | }; 22 | 23 | const external = Object.keys(globals); 24 | 25 | const prefsFooter = [ 26 | 'var init = prefs.init;', 27 | 'var buildPrefsWidget = prefs.buildPrefsWidget;', 28 | ].join('\n') 29 | 30 | export default [ 31 | { 32 | input: 'src/extension.ts', 33 | treeshake: { 34 | moduleSideEffects: 'no-external' 35 | }, 36 | output: { 37 | file: `${buildPath}/extension.js`, 38 | format: 'iife', 39 | name: 'init', 40 | exports: 'default', 41 | globals, 42 | assetFileNames: "[name][extname]", 43 | }, 44 | external, 45 | plugins: [ 46 | commonjs(), 47 | nodeResolve({ 48 | preferBuiltins: false, 49 | }), 50 | typescript({ 51 | tsconfig: './tsconfig.json', 52 | }), 53 | styles({ 54 | mode: ["extract", `stylesheet.css`], 55 | }), 56 | copy({ 57 | targets: [ 58 | { src: './resources/icons', dest: `${buildPath}` }, 59 | { src: './resources/metadata.json', dest: `${buildPath}` }, 60 | { src: './resources/schemas', dest: `${buildPath}` }, 61 | { src: './resources/dbus', dest: `${buildPath}` }, 62 | ], 63 | }), 64 | cleanup({ 65 | comments: 'none' 66 | }), 67 | ], 68 | }, 69 | { 70 | input: 'src/prefs/prefs.ts', 71 | output: { 72 | file: `${buildPath}/prefs.js`, 73 | format: 'iife', 74 | exports: 'default', 75 | name: 'prefs', 76 | footer: prefsFooter, 77 | globals, 78 | }, 79 | treeshake: { 80 | moduleSideEffects: 'no-external' 81 | }, 82 | external, 83 | plugins: [ 84 | commonjs(), 85 | nodeResolve({ 86 | preferBuiltins: false, 87 | }), 88 | typescript({ 89 | tsconfig: './tsconfig.json', 90 | }), 91 | cleanup({ 92 | comments: 'none' 93 | }), 94 | ], 95 | }, 96 | ]; 97 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { Github } from '@esync/api/providers/github'; 2 | import { Gitlab } from '@esync/api/providers/gitlab'; 3 | import { Data, SyncData } from '@esync/data'; 4 | import { getCurrentExtensionSettings, notify } from '@esync/shell'; 5 | import { logger } from '@esync/utils'; 6 | import { Settings } from '@gi-types/gio2'; 7 | import { EventEmitter } from 'events'; 8 | import { Local } from '@esync/api/providers/local'; 9 | import { SyncProvider, SyncEvent, SyncOperationStatus, SyncProviderType } from '@esync/api/types'; 10 | 11 | const debug = logger('api'); 12 | 13 | export class Api { 14 | private provider: SyncProvider; 15 | private eventEmitter: EventEmitter; 16 | private settings: Settings; 17 | private data: Data; 18 | 19 | constructor(eventEmitter: EventEmitter, data: Data) { 20 | this.data = data; 21 | this.settings = getCurrentExtensionSettings(); 22 | this.provider = this.createProvider(); 23 | this.eventEmitter = eventEmitter; 24 | this.eventEmitter.on(SyncEvent.SAVE, this.save.bind(this)); 25 | this.eventEmitter.on(SyncEvent.READ, this.read.bind(this)); 26 | this.settings.connect('changed', this.updateProvider.bind(this)); 27 | } 28 | 29 | async save(): Promise { 30 | debug('got save request, saving settings...'); 31 | try { 32 | const status: SyncOperationStatus = await this.provider.save({ 33 | ...(await this.data.getSyncData()), 34 | }); 35 | if (status === SyncOperationStatus.FAIL) { 36 | throw new Error('Could not save'); 37 | } 38 | debug(`saved settings to ${this.provider.getName()} successfully`); 39 | this.eventEmitter.emit(SyncEvent.SAVE_FINISHED, status); 40 | notify(_(`Settings successfully saved to ${this.provider.getName()}`)); 41 | } catch (ex) { 42 | this.eventEmitter.emit(SyncEvent.SAVE_FINISHED, undefined, ex); 43 | notify(_(`Error occured while saving settings to ${this.provider.getName()}. Please check the logs.`)); 44 | debug(`error occured during save. -> ${ex}`); 45 | } 46 | } 47 | 48 | async read(): Promise { 49 | debug('got read request, reading settings...'); 50 | try { 51 | const result: SyncData = await this.provider.read(); 52 | debug(`read settings from ${this.provider.getName()} successfully`); 53 | this.eventEmitter.emit(SyncEvent.READ_FINISHED, result); 54 | } catch (ex) { 55 | this.eventEmitter.emit(SyncEvent.READ_FINISHED, undefined, ex); 56 | notify(_(`Error occured while reading settings from ${this.provider.getName()}. Please check the logs.`)); 57 | debug(`error occured during read. -> ${ex}`); 58 | } 59 | } 60 | 61 | private createProvider(): SyncProvider { 62 | const providerType = this.settings.get_enum('provider') as SyncProviderType; 63 | debug(`changing provider to ${SyncProviderType[providerType]}`); 64 | 65 | switch (providerType) { 66 | case SyncProviderType.GITHUB: 67 | return this.createGithubProvider(); 68 | case SyncProviderType.GITLAB: 69 | return this.createGitlabProvider(); 70 | case SyncProviderType.LOCAL: 71 | return this.createLocalProvider(); 72 | default: 73 | return this.createGithubProvider(); 74 | } 75 | } 76 | 77 | private updateProvider(): void { 78 | this.provider = this.createProvider(); 79 | } 80 | 81 | private createGithubProvider(): SyncProvider { 82 | const gistId = this.settings.get_string('github-gist-id'); 83 | const userToken = this.settings.get_string('github-user-token'); 84 | 85 | return new Github(gistId, userToken); 86 | } 87 | 88 | private createGitlabProvider(): SyncProvider { 89 | const snippetId = this.settings.get_string('gitlab-snippet-id'); 90 | const userToken = this.settings.get_string('gitlab-user-token'); 91 | 92 | return new Gitlab(snippetId, userToken); 93 | } 94 | 95 | private createLocalProvider(): SyncProvider { 96 | const backupFileLocation = this.settings.get_string('backup-file-location'); 97 | 98 | return new Local(backupFileLocation); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/api/providers/github.ts: -------------------------------------------------------------------------------- 1 | import { SyncData } from '@esync/data'; 2 | import { logger } from '@esync/utils'; 3 | import { Bytes, PRIORITY_DEFAULT } from '@gi-types/glib2'; 4 | import { Message, Session, Status, status_get_phrase } from '@gi-types/soup3'; 5 | import { SyncOperationStatus, SyncProvider } from '../types'; 6 | 7 | const debug = logger('github'); 8 | 9 | export class Github implements SyncProvider { 10 | private static GIST_API_URL = 'https://api.github.com/gists'; 11 | 12 | private gistId: string; 13 | private userToken: string; 14 | private session: Session; 15 | 16 | constructor(gistId: string, userToken: string) { 17 | this.gistId = gistId; 18 | this.userToken = userToken; 19 | this.session = new Session(); 20 | } 21 | 22 | async save(syncData: SyncData): Promise { 23 | const files = Object.keys(syncData).reduce((acc, key) => { 24 | return { 25 | ...acc, 26 | [key]: { 27 | content: JSON.stringify(syncData[key]), 28 | }, 29 | }; 30 | }, {}); 31 | 32 | const message = Message.new('PATCH', `${Github.GIST_API_URL}/${this.gistId}`); 33 | message.request_headers.append('User-Agent', 'Mozilla/5.0'); 34 | message.request_headers.append('Authorization', `token ${this.userToken}`); 35 | const requestBody = JSON.stringify({ 36 | description: 'Extensions Sync', 37 | files, 38 | }); 39 | message.set_request_body_from_bytes('application/json', new Bytes(imports.byteArray.fromString(requestBody))); 40 | await this.session.send_and_read_async(message, PRIORITY_DEFAULT, null); 41 | 42 | const { statusCode } = message; 43 | const phrase = status_get_phrase(statusCode); 44 | if (statusCode !== Status.OK) { 45 | throw new Error(`failed to save data to ${this.getName()}. Server status: ${phrase}`); 46 | } 47 | 48 | return SyncOperationStatus.SUCCESS; 49 | } 50 | 51 | async read(): Promise { 52 | const message = Message.new('GET', `${Github.GIST_API_URL}/${this.gistId}`); 53 | message.request_headers.append('User-Agent', 'Mozilla/5.0'); 54 | message.request_headers.append('Authorization', `token ${this.userToken}`); 55 | 56 | const bytes = await this.session.send_and_read_async(message, PRIORITY_DEFAULT, null); 57 | const { statusCode } = message; 58 | const phrase = status_get_phrase(statusCode); 59 | if (statusCode !== Status.OK) { 60 | throw new Error(`failed to read data from ${this.getName()}. Server status: ${phrase}`); 61 | } 62 | 63 | const data = bytes.get_data(); 64 | if (data === null) { 65 | throw new Error(`failed to read data from ${this.getName()}. Empty response`); 66 | } 67 | 68 | const json = imports.byteArray.toString(data); 69 | const body = JSON.parse(json); 70 | 71 | const syncData: SyncData = Object.keys(body.files).reduce( 72 | (acc, key) => { 73 | try { 74 | return { 75 | ...acc, 76 | [key]: JSON.parse(body.files[key].content), 77 | }; 78 | } catch { 79 | debug(`failed to parse ${key} file. skipping it...`); 80 | return acc; 81 | } 82 | }, 83 | { extensions: {}, keybindings: {}, tweaks: {} }, 84 | ); 85 | 86 | return syncData; 87 | } 88 | 89 | getName(): string { 90 | return 'Github'; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/api/providers/gitlab.ts: -------------------------------------------------------------------------------- 1 | import { SyncData } from '@esync/data'; 2 | import { Bytes, PRIORITY_DEFAULT } from '@gi-types/glib2'; 3 | import { Message, Session, Status, status_get_phrase } from '@gi-types/soup3'; 4 | import { SyncOperationStatus, SyncProvider } from '../types'; 5 | 6 | export class Gitlab implements SyncProvider { 7 | private static SNIPPETS_API_URL = 'https://gitlab.com/api/v4/snippets'; 8 | 9 | private snippetId: string; 10 | private userToken: string; 11 | private session: Session; 12 | 13 | constructor(snippetId: string, userToken: string) { 14 | this.snippetId = snippetId; 15 | this.userToken = userToken; 16 | this.session = new Session(); 17 | } 18 | 19 | async save(syncData: SyncData): Promise { 20 | const message = Message.new('PUT', `${Gitlab.SNIPPETS_API_URL}/${this.snippetId}`); 21 | message.request_headers.append('User-Agent', 'Mozilla/5.0'); 22 | message.request_headers.append('PRIVATE-TOKEN', `${this.userToken}`); 23 | const requestBody = JSON.stringify({ 24 | title: 'Extensions Sync', 25 | content: JSON.stringify(syncData), 26 | }); 27 | message.set_request_body_from_bytes('application/json', new Bytes(imports.byteArray.fromString(requestBody))); 28 | await this.session.send_and_read_async(message, PRIORITY_DEFAULT, null); 29 | 30 | const { statusCode } = message; 31 | const phrase = status_get_phrase(statusCode); 32 | if (statusCode !== Status.OK) { 33 | throw new Error(`failed to save data to ${this.getName()}. Server status: ${phrase}`); 34 | } 35 | 36 | return SyncOperationStatus.SUCCESS; 37 | } 38 | 39 | async read(): Promise { 40 | const message = Message.new('GET', `${Gitlab.SNIPPETS_API_URL}/${this.snippetId}/raw`); 41 | message.request_headers.append('User-Agent', 'Mozilla/5.0'); 42 | message.request_headers.append('PRIVATE-TOKEN', `${this.userToken}`); 43 | 44 | const bytes = await this.session.send_and_read_async(message, PRIORITY_DEFAULT, null); 45 | const { statusCode } = message; 46 | const phrase = status_get_phrase(statusCode); 47 | if (statusCode !== Status.OK) { 48 | throw new Error(`failed to read data from ${this.getName()}. Server status: ${phrase}`); 49 | } 50 | 51 | const data = bytes.get_data(); 52 | if (data === null) { 53 | throw new Error(`failed to read data from ${this.getName()}. Empty response`); 54 | } 55 | 56 | const json = imports.byteArray.toString(data); 57 | const syncData = JSON.parse(json); 58 | 59 | return syncData; 60 | } 61 | 62 | getName(): string { 63 | return 'Gitlab'; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/api/providers/local.ts: -------------------------------------------------------------------------------- 1 | import { SyncData } from '@esync/data'; 2 | import { File, FileCreateFlags } from '@gi-types/gio2'; 3 | import { SyncOperationStatus, SyncProvider } from '../types'; 4 | 5 | export class Local implements SyncProvider { 6 | private backupFileLocation: string; 7 | 8 | constructor(backupFileLocation: string) { 9 | this.backupFileLocation = backupFileLocation; 10 | } 11 | 12 | async save(syncData: SyncData): Promise { 13 | if (!this.backupFileLocation) { 14 | throw new Error('Please select a backup file location from preferences'); 15 | } 16 | const backupFile = File.new_for_uri(this.backupFileLocation); 17 | if (!backupFile.query_exists(null)) { 18 | throw new Error(`Failed to backup settings. ${this.backupFileLocation} does not exist`); 19 | } 20 | 21 | backupFile.replace_contents( 22 | imports.byteArray.fromString(JSON.stringify(syncData)), 23 | null, 24 | false, 25 | FileCreateFlags.REPLACE_DESTINATION, 26 | null, 27 | ); 28 | 29 | return SyncOperationStatus.SUCCESS; 30 | } 31 | 32 | async read(): Promise { 33 | const backupFile = File.new_for_uri(this.backupFileLocation); 34 | if (!backupFile.query_exists(null)) { 35 | throw new Error(`Failed to read settings from backup. ${this.backupFileLocation} does not exist`); 36 | } 37 | 38 | const [status, syncDataBytes] = backupFile.load_contents(null); 39 | 40 | if (!syncDataBytes.length || !status) { 41 | throw new Error(`Failed to read settings from backup. ${this.backupFileLocation} is corrupted`); 42 | } 43 | 44 | try { 45 | return JSON.parse(imports.byteArray.toString(syncDataBytes)); 46 | } catch (err) { 47 | throw new Error(`${this.backupFileLocation} is not a json file`); 48 | } 49 | } 50 | 51 | getName(): string { 52 | return 'Local'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { SyncData } from '@esync/data'; 2 | 3 | export enum SyncOperationStatus { 4 | SUCCESS, 5 | FAIL, 6 | } 7 | 8 | export enum SyncEvent { 9 | SAVE = 'SAVE', 10 | SAVE_FINISHED = 'SAVE_FINISHED', 11 | READ = 'READ', 12 | READ_FINISHED = 'READ_FINISHED', 13 | } 14 | 15 | export enum SyncProviderType { 16 | GITHUB, 17 | GITLAB, 18 | LOCAL, 19 | } 20 | 21 | export interface SyncProvider { 22 | save(syncData: SyncData): Promise; 23 | read(): Promise; 24 | getName(): string; 25 | } 26 | -------------------------------------------------------------------------------- /src/data/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionData, ExtensionsDataProvider } from '@esync/data/providers/extensions/provider'; 2 | import { KeyBindingsData, KeyBindingsDataProvider } from '@esync/data/providers/keybindings'; 3 | import { TweaksData, TweaksDataProvider } from '@esync/data/providers/tweaks'; 4 | import { getCurrentExtensionSettings } from '@esync/shell'; 5 | import { logger, settingsFlagsToEnumList } from '@esync/utils'; 6 | import { Settings } from '@gi-types/gio2'; 7 | 8 | const debug = logger('data'); 9 | 10 | export type SyncData = { 11 | extensions: ExtensionData; 12 | keybindings: KeyBindingsData; 13 | tweaks: TweaksData; 14 | }; 15 | 16 | export enum DataOperationStatus { 17 | SUCCESS, 18 | FAIL, 19 | } 20 | 21 | export enum DataProviderType { 22 | EXTENSIONS, 23 | KEYBINDINGS, 24 | TWEAKS, 25 | } 26 | 27 | export interface DataProvider { 28 | getData(): Promise; 29 | useData(data: ExtensionData | KeyBindingsData | TweaksData): Promise; 30 | getName(): string; 31 | } 32 | 33 | export class Data { 34 | private settings: Settings; 35 | private providers: Array; 36 | 37 | constructor() { 38 | this.settings = getCurrentExtensionSettings(); 39 | this.providers = this.createProviders(); 40 | this.settings.connect('changed', this.updateProviders.bind(this)); 41 | } 42 | 43 | async getSyncData(): Promise { 44 | const resultList = await Promise.all( 45 | this.providers.map(async (provider) => { 46 | return { 47 | [provider.getName()]: await provider.getData(), 48 | }; 49 | }), 50 | ); 51 | 52 | return resultList.reduce( 53 | (acc: SyncData, result) => { 54 | return { 55 | ...acc, 56 | ...result, 57 | }; 58 | }, 59 | { extensions: {}, keybindings: {}, tweaks: {} }, 60 | ); 61 | } 62 | 63 | async use(syncData: SyncData): Promise { 64 | await Promise.all( 65 | this.providers.map(async (provider) => { 66 | debug(`updating ${provider.getName()} settings in local machine`); 67 | await provider.useData(syncData[provider.getName()]); 68 | }), 69 | ); 70 | } 71 | 72 | private createProvider(providerType: DataProviderType): DataProvider { 73 | switch (providerType) { 74 | case DataProviderType.EXTENSIONS: { 75 | return new ExtensionsDataProvider(); 76 | } 77 | case DataProviderType.KEYBINDINGS: { 78 | return new KeyBindingsDataProvider(); 79 | } 80 | case DataProviderType.TWEAKS: { 81 | return new TweaksDataProvider(); 82 | } 83 | } 84 | } 85 | 86 | private createProviders(): Array { 87 | const providerFlag = this.settings.get_flags('data-providers'); 88 | const providerTypes: Array = settingsFlagsToEnumList(providerFlag); 89 | debug(`enabled data providers are ${providerTypes.map((p) => DataProviderType[p])}`); 90 | 91 | return providerTypes 92 | .map((providerType) => this.createProvider(providerType)) 93 | .filter((provider) => provider !== undefined); 94 | } 95 | 96 | private updateProviders(): void { 97 | this.providers = this.createProviders(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/data/providers/extensions/provider.ts: -------------------------------------------------------------------------------- 1 | import { DataProvider } from '@esync/data'; 2 | import { 3 | getAllExtensionConfigData, 4 | getExtensionIds, 5 | installExtension, 6 | removeExtension, 7 | } from '@esync/data/providers/extensions/utils'; 8 | import { writeDconfData } from '@esync/shell'; 9 | import { logger } from '@esync/utils'; 10 | 11 | const debug = logger('extension-provider'); 12 | 13 | export type ExtensionData = { 14 | [key: string]: { 15 | [key: string]: string; 16 | }; 17 | }; 18 | 19 | export class ExtensionsDataProvider implements DataProvider { 20 | async getData(): Promise { 21 | return getAllExtensionConfigData(); 22 | } 23 | 24 | async useData(extensionData: ExtensionData): Promise { 25 | const downloadedExtensions = Object.keys(extensionData); 26 | const localExtensions = getExtensionIds(); 27 | localExtensions.forEach( 28 | (extensionId) => downloadedExtensions.indexOf(extensionId) < 0 && removeExtension(extensionId), 29 | ); 30 | 31 | debug(`downloading extensions: ${downloadedExtensions}`); 32 | 33 | await Promise.all( 34 | downloadedExtensions.map((extensionId) => { 35 | return Object.keys(extensionData[extensionId]).map((schemaPath) => { 36 | return writeDconfData(schemaPath, extensionData[extensionId][schemaPath]); 37 | }); 38 | }), 39 | ); 40 | 41 | await Promise.all( 42 | downloadedExtensions.map( 43 | async (extensionId) => localExtensions.indexOf(extensionId) < 0 && installExtension(extensionId), 44 | ), 45 | ); 46 | } 47 | 48 | getName(): string { 49 | return 'extensions'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/data/providers/extensions/utils.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionType, getCurrentExtension, readDconfData, ShellExtension } from '@esync/shell'; 2 | import { execute, logger } from '@esync/utils'; 3 | import { File, Subprocess, SubprocessFlags } from '@gi-types/gio2'; 4 | import { build_filenamev, file_get_contents, get_user_data_dir, PRIORITY_DEFAULT } from '@gi-types/glib2'; 5 | import { form_encode_hash, Message, Session, Status, status_get_phrase } from '@gi-types/soup3'; 6 | import { parse } from 'fast-xml-parser'; 7 | 8 | const debug = logger('extension-utils'); 9 | 10 | const readSchemaAsJson = (schemaPath: string): any => { 11 | const [, contents] = file_get_contents(schemaPath); 12 | 13 | return parse(imports.byteArray.toString(contents), { ignoreAttributes: false }); 14 | }; 15 | 16 | const getExtensionManager = (): any => imports.ui.main.extensionManager; 17 | 18 | const getExtensionById = (extensionId: string): ShellExtension => getExtensionManager().lookup(extensionId); 19 | 20 | const getExtensionSchemas = async (extensionId: string): Promise => { 21 | const extension = getExtensionById(extensionId); 22 | let stdout: string; 23 | 24 | try { 25 | stdout = await execute(`find -L ${extension.path} -iname "*.xml" -exec grep -l "schemalist" {} +`); 26 | } catch (ex) { 27 | debug(`error occurred while getting extension schemas: ${ex}`); 28 | return {}; 29 | } 30 | 31 | if (!stdout) { 32 | return {}; 33 | } 34 | 35 | const schemaFiles: Array = stdout.split('\n'); 36 | 37 | const foundSchemas = schemaFiles 38 | .map((schemaFile) => readSchemaAsJson(schemaFile)) 39 | .reduce((schemaJsonAcc, schemaJson) => { 40 | if (!schemaJson || !schemaJson.schemalist || !schemaJson.schemalist.schema) { 41 | return schemaJsonAcc; 42 | } 43 | 44 | const schema = schemaJson.schemalist.schema; 45 | 46 | if (Array.isArray(schema)) { 47 | const multipleSchemaObj = schema.reduce((acc, schemaObj) => { 48 | if (schemaObj['@_path']) { 49 | return { 50 | ...acc, 51 | [schemaObj['@_path']]: {}, 52 | }; 53 | } 54 | 55 | return acc; 56 | }, {}); 57 | 58 | return { 59 | ...multipleSchemaObj, 60 | ...schemaJsonAcc, 61 | }; 62 | } else if (schema['@_path']) { 63 | return { 64 | ...schemaJsonAcc, 65 | [schema['@_path']]: {}, 66 | }; 67 | } 68 | 69 | return schemaJsonAcc; 70 | }, {}); 71 | 72 | return foundSchemas; 73 | }; 74 | 75 | export const getExtensionIds = (): Array => 76 | getExtensionManager() 77 | .getUuids() 78 | .filter( 79 | (uuid: string) => 80 | getExtensionById(uuid).type === ExtensionType.PER_USER && uuid !== getCurrentExtension().metadata.uuid, 81 | ); 82 | 83 | const getAllExtensions = (): Array => { 84 | const extensionIds = getExtensionIds(); 85 | const extensions = extensionIds 86 | .map((id: string): any => { 87 | const extension = getExtensionById(id); 88 | if (extension.type === ExtensionType.PER_USER) { 89 | return extension; 90 | } 91 | return undefined; 92 | }) 93 | .filter((item) => item !== undefined); 94 | 95 | return extensions; 96 | }; 97 | 98 | const getExtensionConfigData = async (extensionId: string): Promise => { 99 | const schemas = await getExtensionSchemas(extensionId); 100 | 101 | return Object.keys(schemas).reduce(async (acc, schema) => { 102 | try { 103 | return { 104 | ...(await acc), 105 | [schema]: await readDconfData(schema), 106 | }; 107 | } catch (ex) { 108 | debug(`cannot dump settings for ${extensionId}:${schema}`); 109 | } 110 | 111 | return acc; 112 | }, Promise.resolve({})); 113 | }; 114 | 115 | export const getAllExtensionConfigData = async (): Promise => { 116 | const extensions = getAllExtensions(); 117 | 118 | return extensions.reduce(async (extensionAcc, extension) => { 119 | return { 120 | ...(await extensionAcc), 121 | [extension.metadata.uuid]: await getExtensionConfigData(extension.metadata.uuid), 122 | }; 123 | }, Promise.resolve({})); 124 | }; 125 | 126 | export const removeExtension = (extensionId: string): void => { 127 | imports.ui.extensionDownloader.uninstallExtension(extensionId); 128 | debug(`removed extension ${extensionId}`); 129 | }; 130 | 131 | const extractExtensionArchive = async (bytes, dir) => { 132 | if (!dir.query_exists(null)) { 133 | dir.make_directory_with_parents(null); 134 | } 135 | 136 | const [file, stream] = File.new_tmp('XXXXXX.shell-extension.zip'); 137 | await stream.output_stream.write_bytes_async(bytes, PRIORITY_DEFAULT, null); 138 | stream.close_async(PRIORITY_DEFAULT, null); 139 | 140 | const unzip = Subprocess.new(['unzip', '-uod', dir.get_path(), '--', file.get_path()], SubprocessFlags.NONE); 141 | await unzip.wait_check_async(null); 142 | }; 143 | 144 | export const installExtension = async (extensionId: string): Promise => { 145 | const params = { shell_version: imports.misc.config.PACKAGE_VERSION }; 146 | const message = Message.new_from_encoded_form( 147 | 'GET', 148 | `https://extensions.gnome.org/download-extension/${extensionId}.shell-extension.zip`, 149 | form_encode_hash(params), 150 | ); 151 | 152 | const dir = File.new_for_path(build_filenamev([get_user_data_dir(), 'gnome-shell', 'extensions', extensionId])); 153 | 154 | try { 155 | const bytes = await new Session().send_and_read_async(message, PRIORITY_DEFAULT, null); 156 | const { statusCode } = message; 157 | const phrase = status_get_phrase(statusCode); 158 | if (statusCode !== Status.OK) throw new Error(`Unexpected response: ${phrase}`); 159 | 160 | await extractExtensionArchive(bytes, dir); 161 | 162 | const extension = getExtensionManager().createExtensionObject(extensionId, dir, ExtensionType.PER_USER); 163 | getExtensionManager().loadExtension(extension); 164 | if (!getExtensionManager().enableExtension(extensionId)) { 165 | throw new Error(`Cannot enable ${extensionId}`); 166 | } 167 | } catch (e) { 168 | debug(`error occurred during installation of ${extensionId}. Error: ${e}`); 169 | } 170 | }; 171 | -------------------------------------------------------------------------------- /src/data/providers/keybindings.ts: -------------------------------------------------------------------------------- 1 | import { DataProvider } from '@esync/data'; 2 | import { readDconfData, writeDconfData } from '@esync/shell'; 3 | import { logger } from '@esync/utils'; 4 | 5 | const debug = logger('keybindings-data-provider'); 6 | 7 | export type KeyBindingsData = { 8 | [key: string]: string; 9 | }; 10 | 11 | const keyBindingsSchemaList: Array = [ 12 | '/org/gnome/mutter/keybindings/', 13 | '/org/gnome/mutter/wayland/keybindings/', 14 | '/org/gnome/shell/keybindings/', 15 | '/org/gnome/desktop/wm/keybindings/', 16 | '/org/gnome/settings-daemon/plugins/media-keys/', 17 | ]; 18 | 19 | export class KeyBindingsDataProvider implements DataProvider { 20 | async getData(): Promise { 21 | return keyBindingsSchemaList.reduce(async (acc, schema) => { 22 | try { 23 | return { 24 | ...(await acc), 25 | [schema]: await readDconfData(schema), 26 | }; 27 | } catch (ex) { 28 | debug(`cannot dump settings for ${schema}`); 29 | } 30 | 31 | return acc; 32 | }, Promise.resolve({})); 33 | } 34 | 35 | async useData(keyBindingsData: KeyBindingsData): Promise { 36 | await Promise.all( 37 | Object.keys(keyBindingsData).map((schemaPath) => { 38 | return writeDconfData(schemaPath, keyBindingsData[schemaPath]); 39 | }), 40 | ); 41 | } 42 | 43 | getName(): string { 44 | return 'keybindings'; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/data/providers/tweaks.ts: -------------------------------------------------------------------------------- 1 | import { DataProvider } from '@esync/data'; 2 | import { readDconfData, writeDconfData } from '@esync/shell'; 3 | import { logger } from '@esync/utils'; 4 | 5 | const debug = logger('tweaks-data-provider'); 6 | 7 | export type TweaksData = { 8 | [key: string]: string; 9 | }; 10 | 11 | const tweaksSchemaList: Array = [ 12 | '/org/gnome/desktop/background/', 13 | '/org/gnome/desktop/calendar/', 14 | '/org/gnome/desktop/input-sources/', 15 | '/org/gnome/desktop/interface/', 16 | '/org/gnome/desktop/peripherals/', 17 | '/org/gnome/desktop/screensaver/', 18 | '/org/gnome/desktop/sound/', 19 | '/org/gnome/desktop/wm/preferences/', 20 | '/org/gnome/mutter/', 21 | '/org/gnome/settings-daemon/plugins/xsettings/', 22 | ]; 23 | 24 | export class TweaksDataProvider implements DataProvider { 25 | async getData(): Promise { 26 | return tweaksSchemaList.reduce(async (acc, schema) => { 27 | try { 28 | return { 29 | ...(await acc), 30 | [schema]: await readDconfData(schema), 31 | }; 32 | } catch (ex) { 33 | debug(`cannot dump settings for ${schema}`); 34 | } 35 | 36 | return acc; 37 | }, Promise.resolve({})); 38 | } 39 | 40 | async useData(tweaksData: TweaksData): Promise { 41 | await Promise.all( 42 | Object.keys(tweaksData).map((schemaPath) => { 43 | return writeDconfData(schemaPath, tweaksData[schemaPath]); 44 | }), 45 | ); 46 | } 47 | 48 | getName(): string { 49 | return 'tweaks'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { Api } from '@esync/api'; 2 | import { Data } from '@esync/data'; 3 | import { StatusMenu } from '@esync/panel/statusMenu'; 4 | import { loadInterfaceXML } from '@esync/shell'; 5 | import { Sync } from '@esync/sync'; 6 | import { logger } from '@esync/utils'; 7 | import { DBus, DBusExportedObject } from '@gi-types/gio2'; 8 | import { EventEmitter } from 'events'; 9 | import { SyncEvent } from '@esync/api/types'; 10 | import './styles/stylesheet.css'; 11 | 12 | const debug = logger('extension'); 13 | 14 | class SyncExtension { 15 | private sync: Sync; 16 | private statusMenu: StatusMenu; 17 | private api: Api; 18 | private eventEmitter: EventEmitter; 19 | private data: Data; 20 | private dbus: DBusExportedObject; 21 | 22 | constructor() { 23 | this.eventEmitter = new EventEmitter(); 24 | this.data = new Data(); 25 | this.api = new Api(this.eventEmitter, this.data); 26 | this.sync = new Sync(this.eventEmitter, this.data); 27 | this.statusMenu = new StatusMenu(this.eventEmitter); 28 | const iface = loadInterfaceXML('io.elhan.ExtensionsSync'); 29 | this.dbus = DBusExportedObject.wrapJSObject(iface, this); 30 | 31 | debug('extension is initialized'); 32 | } 33 | 34 | save(): void { 35 | this.eventEmitter.emit(SyncEvent.SAVE); 36 | } 37 | 38 | read(): void { 39 | this.eventEmitter.emit(SyncEvent.READ); 40 | } 41 | 42 | enable(): void { 43 | this.sync.start(); 44 | this.statusMenu.show(); 45 | this.dbus.export(DBus.session, '/io/elhan/ExtensionsSync'); 46 | debug('extension is enabled'); 47 | } 48 | 49 | disable(): void { 50 | this.sync.stop(); 51 | this.statusMenu.hide(); 52 | this.dbus.unexport(); 53 | debug('extension is disabled'); 54 | } 55 | } 56 | 57 | export default function (): SyncExtension { 58 | return new SyncExtension(); 59 | } 60 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare const imports: { 2 | lang: any; 3 | ui: { 4 | main: { 5 | notify: (arg: string) => void; 6 | panel: any; 7 | extensionManager: { 8 | getUuids: () => Array; 9 | lookup: (extensionId: string) => any; 10 | createExtensionObject: (extensionId: string, dir: any, extensionType: number) => any; 11 | }; 12 | }; 13 | panelMenu: any; 14 | popupMenu: any; 15 | extensionDownloader: { 16 | uninstallExtension: (extensionId: string) => void; 17 | }; 18 | }; 19 | misc: { 20 | extensionUtils: { 21 | getCurrentExtension: () => any; 22 | getSettings: () => any; 23 | ExtensionState: any; 24 | ExtensionType: any; 25 | }; 26 | config: any; 27 | }; 28 | byteArray: { 29 | fromString: (input: string) => Uint8Array; 30 | fromArray: (input: number[]) => any; 31 | fromGBytes: (input: any) => Uint8Array; 32 | toString: (x: Uint8Array) => string; 33 | }; 34 | }; 35 | declare const log: (arg: any) => void; 36 | declare const _: (arg: string) => string; 37 | -------------------------------------------------------------------------------- /src/panel/statusMenu.ts: -------------------------------------------------------------------------------- 1 | import { SyncEvent } from '@esync/api/types'; 2 | import { getCurrentExtension, getCurrentExtensionSettings, ShellExtension } from '@esync/shell'; 3 | import { execute, logger } from '@esync/utils'; 4 | import { icon_new_for_string, Settings } from '@gi-types/gio2'; 5 | import { Icon } from '@gi-types/st1'; 6 | import { EventEmitter } from 'events'; 7 | 8 | const { Button } = imports.ui.panelMenu; 9 | const { PopupImageMenuItem, PopupSeparatorMenuItem } = imports.ui.popupMenu; 10 | const { panel } = imports.ui.main; 11 | 12 | const debug = logger('statusMenu'); 13 | 14 | export class StatusMenu { 15 | private eventEmitter: EventEmitter; 16 | private button: any; 17 | private extension: ShellExtension; 18 | private settings: Settings; 19 | 20 | constructor(eventEmitter: EventEmitter) { 21 | this.eventEmitter = eventEmitter; 22 | this.extension = getCurrentExtension(); 23 | this.settings = getCurrentExtensionSettings(); 24 | this.settings.connect('changed::show-tray-icon', this.toggleStatusMenu.bind(this)); 25 | } 26 | 27 | show(): void { 28 | const showTrayIcon = this.settings.get_boolean('show-tray-icon'); 29 | if (!showTrayIcon) { 30 | return; 31 | } 32 | if (this.button === undefined) { 33 | this.button = this.createButton(); 34 | } 35 | 36 | this.eventEmitter.on(SyncEvent.SAVE, this.disableButton.bind(this)); 37 | this.eventEmitter.on(SyncEvent.READ, this.disableButton.bind(this)); 38 | 39 | this.eventEmitter.on(SyncEvent.SAVE_FINISHED, this.enableButton.bind(this)); 40 | this.eventEmitter.on(SyncEvent.READ_FINISHED, this.enableButton.bind(this)); 41 | 42 | panel.addToStatusArea('extensions-sync', this.button); 43 | debug('showing status menu in panel'); 44 | } 45 | 46 | hide(): void { 47 | if (this.button) { 48 | this.button.destroy(); 49 | this.button = undefined; 50 | } 51 | if (panel.statusArea['extensions-sync']) { 52 | panel.statusArea['extensions-sync'].destroy(); 53 | this.eventEmitter.off(SyncEvent.SAVE, this.disableButton.bind(this)); 54 | this.eventEmitter.off(SyncEvent.READ, this.disableButton.bind(this)); 55 | 56 | this.eventEmitter.off(SyncEvent.SAVE_FINISHED, this.enableButton.bind(this)); 57 | this.eventEmitter.off(SyncEvent.READ_FINISHED, this.enableButton.bind(this)); 58 | debug('removing status menu from panel'); 59 | } 60 | } 61 | 62 | private toggleStatusMenu(): void { 63 | const showTrayIcon = this.settings.get_boolean('show-tray-icon'); 64 | if (showTrayIcon) { 65 | this.show(); 66 | } else { 67 | this.hide(); 68 | } 69 | } 70 | 71 | private createButton(): any { 72 | const newButton = new Button(0, _('Sync Settings')); 73 | 74 | newButton.icon = this.createIcon('synced'); 75 | newButton.add_actor(newButton.icon); 76 | 77 | newButton.menu.addMenuItem( 78 | this.createMenuItem(_('Upload'), 'upload', () => this.eventEmitter.emit(SyncEvent.SAVE)), 79 | ); 80 | newButton.menu.addMenuItem( 81 | this.createMenuItem(_('Download'), 'download', () => this.eventEmitter.emit(SyncEvent.READ)), 82 | ); 83 | newButton.menu.addMenuItem(new PopupSeparatorMenuItem()); 84 | newButton.menu.addMenuItem( 85 | this.createMenuItem(_('Preferences'), 'preferences', () => { 86 | execute(`gnome-extensions prefs "${this.extension.metadata.uuid}"`); 87 | }), 88 | ); 89 | 90 | return newButton; 91 | } 92 | 93 | private createMenuItem(menuTitle: string, actionType: string, onClick?: () => void): any { 94 | const menuItem = new PopupImageMenuItem(`${menuTitle}`, this.createIcon(`${actionType}`).gicon); 95 | if (onClick) { 96 | menuItem.connect('activate', () => onClick()); 97 | } 98 | 99 | return menuItem; 100 | } 101 | 102 | private createIcon(iconType: string): Icon { 103 | return new Icon({ 104 | gicon: icon_new_for_string(`${this.extension.path}/icons/extensions-sync-${iconType}-symbolic.svg`), 105 | style_class: 'system-status-icon', 106 | }); 107 | } 108 | 109 | private enableButton(): void { 110 | if (this.button !== undefined) { 111 | this.button.set_reactive(true); 112 | this.button.icon.set_gicon(this.createIcon('synced').gicon); 113 | } 114 | } 115 | 116 | private disableButton(): void { 117 | if (this.button !== undefined) { 118 | this.button.set_reactive(false); 119 | this.button.icon.set_gicon(this.createIcon('syncing').gicon); 120 | this.button.menu.close(); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/prefs/otherPrefs.ts: -------------------------------------------------------------------------------- 1 | import { registerClass } from '@gi-types/gobject2'; 2 | import { Align, Box, Label, ListBoxRow, Orientation, Switch } from '@gi-types/gtk4'; 3 | import { PrefsTab } from '@esync/prefs/prefsTab'; 4 | 5 | export const OtherPrefs = registerClass( 6 | {}, 7 | class OtherPrefs extends PrefsTab { 8 | _init() { 9 | super._init({ 10 | title: 'Other Settings', 11 | orientation: Orientation.VERTICAL, 12 | marginTop: 24, 13 | marginBottom: 24, 14 | marginStart: 12, 15 | marginEnd: 12, 16 | }); 17 | 18 | this.createShowTrayIconSetting(); 19 | this.createShowNotificationsSetting(); 20 | } 21 | 22 | createShowTrayIconSetting(): void { 23 | const showTrayIcon = this.settings.get_boolean('show-tray-icon'); 24 | this.createSetting('Show Tray Icon', 'Controls the visibility of the tray icon.', showTrayIcon, (state) => { 25 | this.settings.set_boolean('show-tray-icon', state); 26 | }); 27 | } 28 | 29 | createShowNotificationsSetting(): void { 30 | const showNotifications = this.settings.get_boolean('show-notifications'); 31 | this.createSetting( 32 | 'Show Notifications', 33 | 'Controls the visibility of the notifications.', 34 | showNotifications, 35 | (state) => { 36 | this.settings.set_boolean('show-notifications', state); 37 | }, 38 | ); 39 | } 40 | 41 | createSetting( 42 | label: string, 43 | description: string, 44 | initialSwitchValue: boolean, 45 | onStateSet: (state: boolean) => void, 46 | ): void { 47 | const row = new ListBoxRow({ 48 | halign: Align.FILL, 49 | valign: Align.FILL, 50 | widthRequest: 100, 51 | activatable: true, 52 | }); 53 | const rowContainer = new Box({ 54 | marginTop: 24, 55 | marginBottom: 24, 56 | marginStart: 15, 57 | marginEnd: 15, 58 | widthRequest: 100, 59 | orientation: Orientation.HORIZONTAL, 60 | }); 61 | 62 | const rowLabelContainer = new Box({ 63 | orientation: Orientation.VERTICAL, 64 | halign: Align.FILL, 65 | valign: Align.FILL, 66 | hexpand: true, 67 | vexpand: true, 68 | }); 69 | rowLabelContainer.append( 70 | new Label({ 71 | label, 72 | halign: Align.START, 73 | valign: Align.FILL, 74 | }), 75 | ); 76 | rowLabelContainer.append( 77 | new Label({ 78 | label: description, 79 | halign: Align.START, 80 | valign: Align.FILL, 81 | cssClasses: ['dim-label', 'setting-description'], 82 | }), 83 | ); 84 | const rowSwitch = new Switch({ 85 | halign: Align.END, 86 | valign: Align.CENTER, 87 | active: initialSwitchValue, 88 | }); 89 | 90 | rowSwitch.connect('state-set', (_, state) => { 91 | onStateSet(state); 92 | }); 93 | 94 | rowContainer.append(rowLabelContainer); 95 | rowContainer.append(rowSwitch); 96 | 97 | row.set_child(rowContainer); 98 | this.append(row); 99 | } 100 | }, 101 | ); 102 | -------------------------------------------------------------------------------- /src/prefs/prefs.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@esync/utils'; 2 | import { registerClass } from '@gi-types/gobject2'; 3 | import { BaselinePosition, Box, Notebook, Orientation } from '@gi-types/gtk4'; 4 | import { OtherPrefs } from '@esync/prefs/otherPrefs'; 5 | import { ProviderPrefs } from '@esync/prefs/providerPrefs'; 6 | import { SyncedDataPrefs } from '@esync/prefs/syncedDataPrefs'; 7 | 8 | const debug = logger('prefs'); 9 | 10 | const Preferences = registerClass( 11 | {}, 12 | class Preferences extends Box { 13 | _init() { 14 | super._init({ 15 | orientation: Orientation.VERTICAL, 16 | spacing: 10, 17 | baselinePosition: BaselinePosition.BOTTOM, 18 | }); 19 | 20 | this.createNotebook(); 21 | } 22 | 23 | createNotebook() { 24 | const notebook = new Notebook({ 25 | hexpand: true, 26 | vexpand: true, 27 | }); 28 | 29 | const providerPrefs = new ProviderPrefs(); 30 | providerPrefs.attach(notebook); 31 | 32 | const syncedDataSettings = new SyncedDataPrefs(); 33 | syncedDataSettings.attach(notebook); 34 | 35 | const otherSettings = new OtherPrefs(); 36 | otherSettings.attach(notebook); 37 | 38 | this.append(notebook); 39 | } 40 | }, 41 | ); 42 | 43 | const init = (): void => debug('prefs initialized'); 44 | 45 | const buildPrefsWidget = (): any => new Preferences(); 46 | 47 | export default { init, buildPrefsWidget }; 48 | -------------------------------------------------------------------------------- /src/prefs/prefsTab.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentExtension, getCurrentExtensionSettings, ShellExtension } from '@esync/shell'; 2 | import { Settings } from '@gi-types/gio2'; 3 | import { registerClass } from '@gi-types/gobject2'; 4 | import { Box, Label, Notebook } from '@gi-types/gtk4'; 5 | 6 | export const PrefsTab = registerClass( 7 | {}, 8 | class PrefsTab extends Box { 9 | private title: string; 10 | protected extension: ShellExtension; 11 | protected settings: Settings; 12 | 13 | _init(params) { 14 | const { title, ...args } = params; 15 | this.title = title; 16 | this.extension = getCurrentExtension(); 17 | this.settings = getCurrentExtensionSettings(); 18 | super._init(args); 19 | } 20 | attach(tab: Notebook): void { 21 | tab.append_page( 22 | this, 23 | new Label({ 24 | label: this.title, 25 | }), 26 | ); 27 | tab.get_page(this).tabExpand = true; 28 | } 29 | }, 30 | ); 31 | -------------------------------------------------------------------------------- /src/prefs/providerPrefs.ts: -------------------------------------------------------------------------------- 1 | import { SyncProviderType } from '@esync/api/types'; 2 | import { File, FileCreateFlags, FilePrototype } from '@gi-types/gio2'; 3 | import { get_user_config_dir } from '@gi-types/glib2'; 4 | import { registerClass } from '@gi-types/gobject2'; 5 | import { 6 | Align, 7 | Box, 8 | Button, 9 | ComboBoxText, 10 | Entry, 11 | FileChooserAction, 12 | FileChooserDialog, 13 | FileFilter, 14 | Label, 15 | Orientation, 16 | ResponseType, 17 | Window, 18 | } from '@gi-types/gtk4'; 19 | import { PrefsTab } from '@esync/prefs/prefsTab'; 20 | 21 | export const ProviderPrefs = registerClass( 22 | {}, 23 | class ProviderPrefs extends PrefsTab { 24 | private githubSettings: Box; 25 | private gitlabSettings: Box; 26 | private localSettings: Box; 27 | 28 | _init() { 29 | super._init({ 30 | title: 'Provider', 31 | orientation: Orientation.VERTICAL, 32 | marginTop: 24, 33 | marginBottom: 24, 34 | marginStart: 12, 35 | marginEnd: 12, 36 | }); 37 | 38 | const providerSelectionContainer = new Box({ 39 | marginTop: 24, 40 | marginBottom: 24, 41 | marginStart: 15, 42 | marginEnd: 15, 43 | orientation: Orientation.VERTICAL, 44 | }); 45 | 46 | providerSelectionContainer.append( 47 | new Label({ 48 | label: 'Provider', 49 | halign: Align.START, 50 | valign: Align.FILL, 51 | marginBottom: 12, 52 | }), 53 | ); 54 | 55 | const providerSelectionCombo = new ComboBoxText({ 56 | marginBottom: 12, 57 | halign: Align.FILL, 58 | valign: Align.FILL, 59 | }); 60 | 61 | providerSelectionCombo.insert(0, 'Github', 'Github'); 62 | providerSelectionCombo.insert(1, 'Gitlab', 'Gitlab'); 63 | providerSelectionCombo.insert(2, 'Local', 'Local'); 64 | 65 | const activeProvider = this.settings.get_enum('provider'); 66 | providerSelectionCombo.set_active(activeProvider); 67 | 68 | providerSelectionCombo.connect('changed', () => { 69 | const newProvider = providerSelectionCombo.get_active(); 70 | this.githubSettings.hide(); 71 | this.gitlabSettings.hide(); 72 | this.localSettings.hide(); 73 | if (newProvider === SyncProviderType.GITHUB) { 74 | this.githubSettings.show(); 75 | } else if (newProvider === SyncProviderType.GITLAB) { 76 | this.gitlabSettings.show(); 77 | } else if (newProvider === SyncProviderType.LOCAL) { 78 | this.localSettings.show(); 79 | } 80 | this.settings.set_enum('provider', newProvider); 81 | }); 82 | 83 | providerSelectionContainer.append(providerSelectionCombo); 84 | 85 | this.append(providerSelectionContainer); 86 | this.githubSettings = this.createRemoteSettings( 87 | 'Gist Id', 88 | 'github-gist-id', 89 | 'github-user-token', 90 | activeProvider === 0, 91 | ); 92 | this.append(this.githubSettings); 93 | 94 | this.gitlabSettings = this.createRemoteSettings( 95 | 'Snippet Id', 96 | 'gitlab-snippet-id', 97 | 'gitlab-user-token', 98 | activeProvider === 1, 99 | ); 100 | this.append(this.gitlabSettings); 101 | 102 | this.localSettings = this.createLocalSettings(activeProvider === 2); 103 | this.append(this.localSettings); 104 | } 105 | 106 | createRemoteSettings( 107 | locationText: string, 108 | locationSettingKey: string, 109 | userTokenSettingKey: string, 110 | show: boolean, 111 | ): Box { 112 | const container = new Box({ 113 | orientation: Orientation.VERTICAL, 114 | marginBottom: 10, 115 | marginStart: 15, 116 | marginEnd: 15, 117 | }); 118 | 119 | const locationContainer = new Box({ 120 | orientation: Orientation.VERTICAL, 121 | }); 122 | 123 | locationContainer.append( 124 | new Label({ 125 | label: locationText, 126 | marginBottom: 6, 127 | halign: Align.START, 128 | valign: Align.FILL, 129 | }), 130 | ); 131 | 132 | const locationEntry = new Entry({ 133 | marginBottom: 24, 134 | halign: Align.FILL, 135 | valign: Align.BASELINE, 136 | }); 137 | locationEntry.set_text(this.settings.get_string(locationSettingKey)); 138 | 139 | locationEntry.connect('changed', () => { 140 | this.settings.set_string(locationSettingKey, locationEntry.get_text()); 141 | }); 142 | 143 | locationContainer.append(locationEntry); 144 | 145 | container.append(locationContainer); 146 | 147 | const userTokenContainer = new Box({ 148 | orientation: Orientation.VERTICAL, 149 | }); 150 | 151 | userTokenContainer.append( 152 | new Label({ 153 | label: 'User Token', 154 | marginBottom: 6, 155 | halign: Align.START, 156 | valign: Align.FILL, 157 | }), 158 | ); 159 | 160 | const userTokenEntry = new Entry({ 161 | marginBottom: 24, 162 | halign: Align.FILL, 163 | valign: Align.BASELINE, 164 | }); 165 | userTokenEntry.set_text(this.settings.get_string(userTokenSettingKey)); 166 | 167 | userTokenEntry.connect('changed', () => { 168 | this.settings.set_string(userTokenSettingKey, userTokenEntry.get_text()); 169 | }); 170 | 171 | userTokenContainer.append(userTokenEntry); 172 | 173 | container.append(userTokenContainer); 174 | 175 | if (show === false) { 176 | container.hide(); 177 | } 178 | 179 | return container; 180 | } 181 | 182 | createLocalSettings(show: boolean): Box { 183 | const container = new Box({ 184 | orientation: Orientation.VERTICAL, 185 | marginBottom: 10, 186 | marginStart: 15, 187 | marginEnd: 15, 188 | }); 189 | 190 | const locationContainer = new Box({ 191 | orientation: Orientation.VERTICAL, 192 | }); 193 | 194 | locationContainer.append( 195 | new Label({ 196 | label: 'Backup File', 197 | marginBottom: 6, 198 | halign: Align.START, 199 | valign: Align.FILL, 200 | }), 201 | ); 202 | 203 | const backupFileUri = this.settings.get_string('backup-file-location'); 204 | let buttonLabel = 'Select backup file location'; 205 | if (backupFileUri) { 206 | const backupFile = File.new_for_uri(backupFileUri); 207 | if (backupFile.query_exists(null)) { 208 | buttonLabel = backupFile.get_uri(); 209 | } 210 | } 211 | const locationButton = new Button({ 212 | marginBottom: 24, 213 | halign: Align.FILL, 214 | label: buttonLabel, 215 | valign: Align.BASELINE, 216 | }); 217 | locationButton.connect('clicked', () => { 218 | const dialog = new FileChooserDialog({ 219 | title: 'Select backup file location', 220 | }); 221 | dialog.set_action(FileChooserAction.SAVE); 222 | dialog.set_select_multiple(false); 223 | 224 | dialog.set_current_folder(File.new_for_path(get_user_config_dir())); 225 | 226 | // Add the buttons and its return values 227 | dialog.add_button('Cancel', ResponseType.CANCEL); 228 | dialog.add_button('OK', ResponseType.OK); 229 | const filter = new FileFilter(); 230 | filter.add_pattern('*.json'); 231 | dialog.set_filter(filter); 232 | dialog.set_transient_for(this.get_root() as any as Window); 233 | dialog.connect('response', (_, response) => { 234 | if (response === ResponseType.OK) { 235 | const backupFile: FilePrototype | null = dialog.get_file(); 236 | if (backupFile) { 237 | if (!backupFile.query_exists(null)) { 238 | backupFile.create(FileCreateFlags.PRIVATE, null); 239 | } 240 | locationButton.label = backupFile.get_uri(); 241 | this.settings.set_string('backup-file-location', backupFile.get_uri()); 242 | } 243 | } 244 | 245 | dialog.destroy(); 246 | }); 247 | 248 | dialog.show(); 249 | }); 250 | 251 | locationContainer.append(locationButton); 252 | 253 | container.append(locationContainer); 254 | 255 | if (show === false) { 256 | container.hide(); 257 | } 258 | 259 | return container; 260 | } 261 | }, 262 | ); 263 | -------------------------------------------------------------------------------- /src/prefs/syncedDataPrefs.ts: -------------------------------------------------------------------------------- 1 | import { DataProviderType } from '@esync/data'; 2 | import { settingsFlagsToEnumList } from '@esync/utils'; 3 | import { registerClass } from '@gi-types/gobject2'; 4 | import { Align, Box, Label, ListBoxRow, Orientation, Switch } from '@gi-types/gtk4'; 5 | import { PrefsTab } from '@esync/prefs/prefsTab'; 6 | 7 | export const SyncedDataPrefs = registerClass( 8 | {}, 9 | class SyncedDataPrefs extends PrefsTab { 10 | _init() { 11 | super._init({ 12 | title: 'Synced Data', 13 | orientation: Orientation.VERTICAL, 14 | marginTop: 24, 15 | marginBottom: 24, 16 | marginStart: 12, 17 | marginEnd: 12, 18 | }); 19 | 20 | this.createSettingRows( 21 | 'Extensions', 22 | 'Syncs all extensions and their configurations.', 23 | DataProviderType.EXTENSIONS, 24 | ); 25 | this.createSettingRows('Keybindings', 'Syncs all gnome shell and gtk keybindings.', DataProviderType.KEYBINDINGS); 26 | this.createSettingRows('Tweaks', 'Syncs gnome settings changed from tweak tool.', DataProviderType.TWEAKS); 27 | } 28 | 29 | createSettingRows(label: string, description: string, dataProviderType: DataProviderType): void { 30 | const providerFlag = this.settings.get_flags('data-providers'); 31 | const providerTypes: Array = settingsFlagsToEnumList(providerFlag); 32 | const row = new ListBoxRow({ 33 | halign: Align.FILL, 34 | valign: Align.FILL, 35 | widthRequest: 100, 36 | activatable: true, 37 | }); 38 | const rowContainer = new Box({ 39 | marginTop: 24, 40 | marginBottom: 24, 41 | marginStart: 15, 42 | marginEnd: 15, 43 | widthRequest: 100, 44 | orientation: Orientation.HORIZONTAL, 45 | }); 46 | 47 | const rowLabelContainer = new Box({ 48 | orientation: Orientation.VERTICAL, 49 | halign: Align.FILL, 50 | valign: Align.FILL, 51 | hexpand: true, 52 | vexpand: true, 53 | }); 54 | rowLabelContainer.append( 55 | new Label({ 56 | label, 57 | halign: Align.START, 58 | valign: Align.FILL, 59 | }), 60 | ); 61 | rowLabelContainer.append( 62 | new Label({ 63 | label: description, 64 | halign: Align.START, 65 | valign: Align.FILL, 66 | cssClasses: ['dim-label', 'setting-description'], 67 | }), 68 | ); 69 | const rowSwitch = new Switch({ 70 | halign: Align.END, 71 | valign: Align.CENTER, 72 | active: providerTypes.find((providerType) => providerType === dataProviderType) !== undefined, 73 | }); 74 | 75 | rowSwitch.connect('state-set', (_, state) => { 76 | let lastProviderFlag = this.settings.get_flags('data-providers'); 77 | if (state === true) { 78 | lastProviderFlag += Math.pow(2, dataProviderType); 79 | } else { 80 | lastProviderFlag -= Math.pow(2, dataProviderType); 81 | } 82 | this.settings.set_flags('data-providers', lastProviderFlag); 83 | }); 84 | 85 | rowContainer.append(rowLabelContainer); 86 | rowContainer.append(rowSwitch); 87 | 88 | row.set_child(rowContainer); 89 | this.append(row); 90 | } 91 | }, 92 | ); 93 | -------------------------------------------------------------------------------- /src/shell/index.ts: -------------------------------------------------------------------------------- 1 | import { execute, logger } from '@esync/utils'; 2 | import { File, FileCreateFlags, file_new_tmp, Settings } from '@gi-types/gio2'; 3 | import { PRIORITY_DEFAULT } from '@gi-types/glib2'; 4 | import { is_wayland_compositor, restart } from '@gi-types/meta10'; 5 | 6 | const debug = logger('shell'); 7 | 8 | export enum ExtensionType { 9 | SYSTEM = 1, 10 | PER_USER = 2, 11 | } 12 | 13 | export enum ExtensionState { 14 | ENABLED = 1, 15 | DISABLED = 2, 16 | ERROR = 3, 17 | OUT_OF_DATE = 4, 18 | DOWNLOADING = 5, 19 | INITIALIZED = 6, 20 | 21 | // Used as an error state for operations on unknown extensions, 22 | // should never be in a real extensionMeta object. 23 | UNINSTALLED = 99, 24 | } 25 | 26 | export interface ShellExtension { 27 | canChange: boolean; 28 | dir: File; 29 | error: any; 30 | hasPrefs: boolean; 31 | hasUpdate: boolean; 32 | imports: any; 33 | metadata: { 34 | name: string; 35 | description: string; 36 | uuid: string; 37 | 'settings-schema': string; 38 | 'shell-version': Array; 39 | }; 40 | path: string; 41 | state: ExtensionState; 42 | stateObj: any; 43 | stylesheet: File; 44 | type: ExtensionType; 45 | uuid: string; 46 | } 47 | 48 | export const getCurrentExtension = (): ShellExtension => imports.misc.extensionUtils.getCurrentExtension(); 49 | 50 | export const getCurrentExtensionSettings = (): Settings => imports.misc.extensionUtils.getSettings(); 51 | 52 | export const canRestartShell = (): boolean => { 53 | return !is_wayland_compositor(); 54 | }; 55 | 56 | export const restartShell = (text: string): void => { 57 | if (!is_wayland_compositor()) { 58 | restart(text); 59 | } 60 | }; 61 | 62 | export const notify = (text: string): void => { 63 | const settings = getCurrentExtensionSettings(); 64 | const showNotifications = settings.get_boolean('show-notifications'); 65 | if (showNotifications) { 66 | imports.ui.main.notify(text); 67 | } else { 68 | debug(`Notifications are hidden. Logging the content instead. Content: ${text}`); 69 | } 70 | }; 71 | 72 | export const writeDconfData = async (schemaPath: string, data: string): Promise => { 73 | if (!schemaPath || !data) { 74 | return; 75 | } 76 | const [file, ioStream] = file_new_tmp(null); 77 | file.replace_contents(imports.byteArray.fromString(data), null, false, FileCreateFlags.REPLACE_DESTINATION, null); 78 | try { 79 | await execute(`dconf load ${schemaPath} < ${file.get_path()}`); 80 | debug(`loaded settings for ${schemaPath}`); 81 | } catch (ex) { 82 | debug(`cannot load settings for ${schemaPath}`); 83 | } 84 | file.delete(null); 85 | ioStream.close_async(PRIORITY_DEFAULT, null); 86 | }; 87 | 88 | export const readDconfData = async (schemaPath: string): Promise => { 89 | return execute(`dconf dump ${schemaPath}`); 90 | }; 91 | 92 | export const loadInterfaceXML = (iface: string): any => { 93 | const uri = `file:///${getCurrentExtension().path}/dbus/${iface}.xml`; 94 | const file = File.new_for_uri(uri); 95 | 96 | try { 97 | const [, bytes] = file.load_contents(null); 98 | return imports.byteArray.toString(bytes); 99 | } catch (e) { 100 | log(`Failed to load D-Bus interface ${iface}`); 101 | } 102 | 103 | return null; 104 | }; 105 | -------------------------------------------------------------------------------- /src/styles/stylesheet.css: -------------------------------------------------------------------------------- 1 | /* Add your custom extension styling here */ 2 | -------------------------------------------------------------------------------- /src/sync/index.ts: -------------------------------------------------------------------------------- 1 | import { SyncEvent } from '@esync/api/types'; 2 | import { Data, SyncData } from '@esync/data'; 3 | import { canRestartShell, notify, restartShell } from '@esync/shell'; 4 | import { EventEmitter } from 'events'; 5 | import { logger } from '@esync/utils'; 6 | 7 | export enum SyncEvents { 8 | SYNCHRONIZED, 9 | } 10 | 11 | const debug = logger('sync'); 12 | 13 | export class Sync { 14 | private eventEmitter: EventEmitter; 15 | private data: Data; 16 | 17 | constructor(eventEmitter: EventEmitter, data: Data) { 18 | this.data = data; 19 | this.eventEmitter = eventEmitter; 20 | } 21 | 22 | start(): void { 23 | this.eventEmitter.on(SyncEvent.READ_FINISHED, this.onReadFinished.bind(this)); 24 | debug('listening for read completion events'); 25 | } 26 | 27 | stop(): void { 28 | this.eventEmitter.off(SyncEvent.READ_FINISHED, this.onReadFinished.bind(this)); 29 | debug('stopped listening for read completion events'); 30 | } 31 | 32 | private async onReadFinished(syncData?: SyncData): Promise { 33 | if (syncData === undefined) { 34 | return; 35 | } 36 | 37 | try { 38 | await this.data.use(syncData); 39 | } catch (ex) { 40 | notify(_('Failed to apply sync data to current system.')); 41 | debug(`failed to apply sync data to system: ${ex}`); 42 | } 43 | 44 | if (canRestartShell()) { 45 | restartShell(_('Extensions are updated. Reloading Gnome Shell')); 46 | } else { 47 | notify(_('Extensions are updated. Please reload Gnome Shell')); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { AsyncResult, Subprocess, SubprocessFlags } from '@gi-types/gio2'; 2 | import { PRIORITY_DEFAULT, Source, timeout_add } from '@gi-types/glib2'; 3 | 4 | export const logger = 5 | (prefix: string) => 6 | (content: string): void => 7 | log(`[extensions-sync] [${prefix}] ${content}`); 8 | 9 | export const setTimeout = (func: () => any, millis: number): number => { 10 | return timeout_add(PRIORITY_DEFAULT, millis, () => { 11 | func(); 12 | 13 | return false; 14 | }); 15 | }; 16 | 17 | export const clearTimeout = (id: number): boolean => Source.remove(id); 18 | 19 | export const execute = async (command: string): Promise => { 20 | const process = new Subprocess({ 21 | argv: ['bash', '-c', command], 22 | flags: SubprocessFlags.STDOUT_PIPE, 23 | }); 24 | 25 | process.init(null); 26 | 27 | return new Promise((resolve, reject) => { 28 | process.communicate_utf8_async(null, null, (_, result: AsyncResult) => { 29 | const [, stdout, stderr] = process.communicate_utf8_finish(result); 30 | if (stderr) { 31 | reject(stderr); 32 | } else if (stdout) { 33 | resolve(stdout.trim()); 34 | } else { 35 | resolve(''); 36 | } 37 | }); 38 | }); 39 | }; 40 | 41 | export const settingsFlagsToEnumList = (flags: number): Array => 42 | flags 43 | .toString(2) 44 | .split('') 45 | .reverse() 46 | .map((state) => parseInt(state, 10)) 47 | .map((state, index) => { 48 | if (state === 1) { 49 | return index; 50 | } 51 | }) 52 | .filter((value) => value !== undefined); 53 | 54 | export const enumListToSettingsFlags = (enumList: Array): number => 55 | enumList.reduce((acc, enumValue) => { 56 | return acc + Math.pow(2, enumValue); 57 | }, 0); 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2017"], 4 | "target": "ES2017", 5 | "strict": true, 6 | "noImplicitAny": false, 7 | "strictNullChecks": true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "allowJs": true, 11 | "strictPropertyInitialization": false, 12 | "baseUrl": "./src/", 13 | "moduleResolution": "Node", 14 | "paths": { 15 | "@esync/*": ["./*"] 16 | } 17 | } 18 | } 19 | --------------------------------------------------------------------------------