├── .editorconfig ├── .eslintrc ├── .gitignore ├── .idea ├── inspectionProfiles │ └── Project_Default.xml ├── jsLibraryMappings.xml ├── jsLinters │ └── eslint.xml ├── modules.xml ├── timestamp.iml └── vcs.xml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── assets │ ├── backgrounds │ │ └── gradient.svg │ ├── icons │ │ ├── bell.svg │ │ ├── calendar.svg │ │ ├── command.svg │ │ ├── power.svg │ │ └── settings.svg │ └── logo.svg ├── components │ ├── Calendar.js │ ├── Clock.js │ ├── Locale.js │ ├── Logger.js │ ├── Preferences.js │ ├── SystemTray.js │ ├── Updater.js │ └── Window.js ├── index.js ├── ipc.js ├── locales │ ├── de.js │ ├── en.js │ └── index.js ├── paths.js ├── styles │ ├── components │ │ ├── button-primary.css │ │ ├── container-alert.css │ │ ├── form-group.css │ │ ├── fx-bounce.css │ │ ├── icon-dots.css │ │ ├── is-draggable.css │ │ ├── is-native.css │ │ └── list-shortcuts.css │ ├── meta │ │ ├── colors.css │ │ ├── fx.css │ │ ├── grid.css │ │ └── typography.css │ ├── shared │ │ ├── base.css │ │ └── typography.css │ └── styles.css └── views │ ├── calendar │ ├── calendar.css │ ├── calendar.html │ ├── ipc.js │ ├── preload.js │ └── renderer.js │ ├── common │ ├── dynamic-image.js │ └── translation-key.js │ └── preferences │ ├── ipc.js │ ├── preferences.css │ ├── preferences.html │ ├── preload.js │ └── renderer.js ├── build ├── entitlements.mac.plist └── icon.icns ├── package-lock.json ├── package.json └── scripts └── notarize.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | # editorconfig-tools is unable to ignore longs strings or urls 11 | max_line_length = off 12 | 13 | [*.md] 14 | indent_size = 4 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "airbnb-base" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 13, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "import/extensions": "off", 16 | "import/no-relative-packages": "off", 17 | "max-len": "off", 18 | "no-console": "off", 19 | "no-restricted-syntax": "off" 20 | }, 21 | "settings": { 22 | "import/core-modules": ["electron"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,windows,intellij 4 | 5 | ### Intellij ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # Crashlytics plugin (for Android Studio and IntelliJ) 67 | com_crashlytics_export_strings.xml 68 | crashlytics.properties 69 | crashlytics-build.properties 70 | fabric.properties 71 | 72 | # Editor-based Rest Client 73 | .idea/httpRequests 74 | 75 | # Android studio 3.1+ serialized cache file 76 | .idea/caches/build_file_checksums.ser 77 | 78 | ### Intellij Patch ### 79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 80 | 81 | # *.iml 82 | # modules.xml 83 | # .idea/misc.xml 84 | # *.ipr 85 | 86 | # Sonarlint plugin 87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 88 | .idea/**/sonarlint/ 89 | 90 | # SonarQube Plugin 91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 92 | .idea/**/sonarIssues.xml 93 | 94 | # Markdown Navigator plugin 95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 96 | .idea/**/markdown-navigator.xml 97 | .idea/**/markdown-navigator-enh.xml 98 | .idea/**/markdown-navigator/ 99 | 100 | # Cache file creation bug 101 | # See https://youtrack.jetbrains.com/issue/JBR-2257 102 | .idea/$CACHE_FILE$ 103 | 104 | # CodeStream plugin 105 | # https://plugins.jetbrains.com/plugin/12206-codestream 106 | .idea/codestream.xml 107 | 108 | ### macOS ### 109 | # General 110 | .DS_Store 111 | .AppleDouble 112 | .LSOverride 113 | 114 | # Icon must end with two \r 115 | Icon 116 | 117 | 118 | # Thumbnails 119 | ._* 120 | 121 | # Files that might appear in the root of a volume 122 | .DocumentRevisions-V100 123 | .fseventsd 124 | .Spotlight-V100 125 | .TemporaryItems 126 | .Trashes 127 | .VolumeIcon.icns 128 | .com.apple.timemachine.donotpresent 129 | 130 | # Directories potentially created on remote AFP share 131 | .AppleDB 132 | .AppleDesktop 133 | Network Trash Folder 134 | Temporary Items 135 | .apdisk 136 | 137 | ### Node ### 138 | # Logs 139 | logs 140 | *.log 141 | npm-debug.log* 142 | yarn-debug.log* 143 | yarn-error.log* 144 | lerna-debug.log* 145 | 146 | # Diagnostic reports (https://nodejs.org/api/report.html) 147 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 148 | 149 | # Runtime data 150 | pids 151 | *.pid 152 | *.seed 153 | *.pid.lock 154 | 155 | # Directory for instrumented libs generated by jscoverage/JSCover 156 | lib-cov 157 | 158 | # Coverage directory used by tools like istanbul 159 | coverage 160 | *.lcov 161 | 162 | # nyc test coverage 163 | .nyc_output 164 | 165 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 166 | .grunt 167 | 168 | # Bower dependency directory (https://bower.io/) 169 | bower_components 170 | 171 | # node-waf configuration 172 | .lock-wscript 173 | 174 | # Compiled binary addons (https://nodejs.org/api/addons.html) 175 | build/Release 176 | 177 | # Dependency directories 178 | node_modules/ 179 | jspm_packages/ 180 | 181 | # TypeScript v1 declaration files 182 | typings/ 183 | 184 | # TypeScript cache 185 | *.tsbuildinfo 186 | 187 | # Optional npm cache directory 188 | .npm 189 | 190 | # Optional eslint cache 191 | .eslintcache 192 | 193 | # Optional stylelint cache 194 | .stylelintcache 195 | 196 | # Microbundle cache 197 | .rpt2_cache/ 198 | .rts2_cache_cjs/ 199 | .rts2_cache_es/ 200 | .rts2_cache_umd/ 201 | 202 | # Optional REPL history 203 | .node_repl_history 204 | 205 | # Output of 'npm pack' 206 | *.tgz 207 | 208 | # Yarn Integrity file 209 | .yarn-integrity 210 | 211 | # dotenv environment variables file 212 | .env 213 | .env.test 214 | .env*.local 215 | 216 | # parcel-bundler cache (https://parceljs.org/) 217 | .cache 218 | .parcel-cache 219 | 220 | # Next.js build output 221 | .next 222 | 223 | # Nuxt.js build / generate output 224 | .nuxt 225 | dist 226 | 227 | # Storybook build outputs 228 | .out 229 | .storybook-out 230 | storybook-static 231 | 232 | # rollup.js default build output 233 | dist/ 234 | 235 | # Gatsby files 236 | .cache/ 237 | # Comment in the public line in if your project uses Gatsby and not Next.js 238 | # https://nextjs.org/blog/next-9-1#public-directory-support 239 | # public 240 | 241 | # vuepress build output 242 | .vuepress/dist 243 | 244 | # Serverless directories 245 | .serverless/ 246 | 247 | # FuseBox cache 248 | .fusebox/ 249 | 250 | # DynamoDB Local files 251 | .dynamodb/ 252 | 253 | # TernJS port file 254 | .tern-port 255 | 256 | # Stores VSCode versions used for testing VSCode extensions 257 | .vscode-test 258 | 259 | # Temporary folders 260 | tmp/ 261 | temp/ 262 | 263 | ### Windows ### 264 | # Windows thumbnail cache files 265 | Thumbs.db 266 | Thumbs.db:encryptable 267 | ehthumbs.db 268 | ehthumbs_vista.db 269 | 270 | # Dump file 271 | *.stackdump 272 | 273 | # Folder config file 274 | [Dd]esktop.ini 275 | 276 | # Recycle Bin used on file shares 277 | $RECYCLE.BIN/ 278 | 279 | # Windows Installer files 280 | *.cab 281 | *.msi 282 | *.msix 283 | *.msm 284 | *.msp 285 | 286 | # Windows shortcuts 287 | *.lnk 288 | 289 | # End of https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij 290 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/timestamp.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.0.1] 2 | ###### 2016-09-09 3 | 4 | A tiny patch release which eliminates two bugs. 🐞 5 | 6 | ###### Fixed 7 | - Calendar window position being offset on multiple monitors 8 | - Preferences and about window being opened multiple times 9 | 10 | # 1.0.0 11 | ###### 2016-09-04 12 | 13 | First public release! 🎉 14 | 15 | [1.0.1]: https://github.com/mzdr/timestamp/compare/1.0.0...1.0.1 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Sebastian Prein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo of Timestamp 3 |

Timestamp

4 | Latest Timestamp release 5 |

6 | 7 | A better macOS menu bar clock with a customizable date/time display and a calendar. Inspired by [Day-O]. 8 | 9 | Built with [Electron] and [date-fns]. 10 | 11 | ## Screenshots 12 | 13 | ![Screenshot of Timestamp app](https://mzdr.github.io/timestamp/screenshot.jpg) 14 | 15 | ## Install 16 | 17 | ### Manual 18 | **[Download]**, unzip, and move `Timestamp.app` to the `/Applications` directory. 19 | 20 | ### Homebrew 21 | Simply run `brew install --cask timestamp` in your terminal. 22 | 23 | ## Support 24 | 25 | **Bugs and requests**: Please use the project's [issue tracker]. 26 | [![Issues](http://img.shields.io/github/issues/mzdr/timestamp.svg)](https://github.com/mzdr/timestamp/issues) 27 | 28 | **Want to contribute?** Please fork this repository and open a pull request with your new shiny stuff. 🌟 29 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/mzdr/timestamp.svg?maxAge=3600)](https://github.com/mzdr/timestamp/pulls) 30 | 31 | **Do you like it?** Support the project by starring the repository or [tweet] about it. 32 | 33 | ## Thanks 34 | 35 | **Timestamp** © 2021, Sebastian Prein. Released under the [MIT License]. 36 | 37 | [Day-O]: http://shauninman.com/archive/2011/10/20/day_o_mac_menu_bar_clock 38 | [Electron]: http://electron.atom.io/ 39 | [date-fns]: https://date-fns.org/ 40 | [MIT License]: https://mit-license.org/ 41 | [issue tracker]: https://github.com/mzdr/timestamp/issues/new 42 | [tweet]: https://twitter.com/intent/tweet?url=https://github.com/mzdr/timestamp&text=Timestamp,%20a%20better%20macOS%20menu%20bar%20clock%20with%20a%20customizable%20date/time%20display%20and%20a%20calendar.%20%E2%80%94 43 | [customizable]: https://date-fns.org/docs/format 44 | [Download]: https://github.com/mzdr/timestamp/releases/latest 45 | [support]: #support 46 | -------------------------------------------------------------------------------- /app/assets/backgrounds/gradient.svg: -------------------------------------------------------------------------------- 1 | 2 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/assets/icons/bell.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/assets/icons/calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/assets/icons/command.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/assets/icons/power.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/components/Calendar.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const { resolve } = require('path'); 3 | const datefns = require('date-fns'); 4 | 5 | const Window = require('./Window'); 6 | 7 | const { 8 | CALENDAR_GET_CALENDAR, 9 | CALENDAR_GET_DATE, 10 | CALENDAR_GET_WEEKDAYS, 11 | CALENDAR_HIDE, 12 | CALENDAR_SHOW, 13 | } = require('../views/calendar/ipc'); 14 | 15 | class Calendar { 16 | constructor({ locale, logger }) { 17 | this.logger = logger; 18 | this.locale = locale.getObject(); 19 | 20 | ipcMain.handle(CALENDAR_GET_CALENDAR, this.getCalendar.bind(this)); 21 | ipcMain.handle(CALENDAR_GET_DATE, this.getDate.bind(this)); 22 | ipcMain.handle(CALENDAR_GET_WEEKDAYS, this.getWeekdays.bind(this)); 23 | 24 | ipcMain.on(CALENDAR_HIDE, () => this.window.hide()); 25 | ipcMain.on(CALENDAR_SHOW, () => this.window.show()); 26 | 27 | this.window = new Window({ 28 | name: 'calendar', 29 | sourceFile: resolve(__dirname, '../views/calendar/calendar.html'), 30 | webPreferences: { 31 | preload: resolve(__dirname, '../views/calendar/preload.js'), 32 | backgroundThrottling: false, 33 | }, 34 | }); 35 | 36 | this.logger.debug('Calendar module created.'); 37 | } 38 | 39 | getDate(event, payload = {}) { 40 | const { locale } = this; 41 | 42 | const { 43 | date, 44 | format, 45 | set, 46 | diff, 47 | } = payload; 48 | 49 | let final = date || new Date(); 50 | 51 | if (set) { 52 | final = datefns.set(final, set); 53 | } 54 | 55 | if (diff) { 56 | final = datefns.add(final, diff); // date-fns.add() supports negative numbers as well 57 | } 58 | 59 | if (format) { 60 | try { 61 | return datefns.format(final, format, { locale }); 62 | } catch (error) { 63 | return '#invalid format#'; 64 | } 65 | } 66 | 67 | return final; 68 | } 69 | 70 | getCalendar(event, payload) { 71 | const { locale } = this; 72 | const year = this.getDate(null, payload); 73 | const startOfYear = datefns.startOfYear(year); 74 | const endOfYear = datefns.endOfYear(year); 75 | const totalDays = datefns.differenceInCalendarDays(endOfYear, startOfYear); 76 | 77 | const days = []; 78 | 79 | for (let i = 0; i <= totalDays; i += 1) { 80 | const date = datefns.addDays(startOfYear, i); 81 | const week = datefns.getWeek(date, { locale }); 82 | const weekday = datefns.getDay(date); 83 | const day = datefns.format(date, 'd', { locale }); 84 | 85 | days.push({ 86 | date, 87 | day, 88 | week, 89 | weekday, 90 | }); 91 | } 92 | 93 | return days; 94 | } 95 | 96 | getWeekdays() { 97 | const { locale } = this; 98 | const startOfWeek = datefns.startOfWeek(new Date(), { locale }); 99 | const startIndex = datefns.getDay(startOfWeek); 100 | const weekdays = []; 101 | 102 | for (let i = 0; i < 7; i += 1) { 103 | const weekday = datefns.addDays(startOfWeek, i); 104 | 105 | weekdays.push( 106 | datefns.format(weekday, 'EEE', { locale }), 107 | ); 108 | } 109 | 110 | return { 111 | startIndex, 112 | weekdays, 113 | }; 114 | } 115 | } 116 | 117 | module.exports = Calendar; 118 | -------------------------------------------------------------------------------- /app/components/Clock.js: -------------------------------------------------------------------------------- 1 | const datefns = require('date-fns'); 2 | 3 | class Clock { 4 | #tickId = null; 5 | 6 | constructor(options = {}) { 7 | const { onTick, locale, format } = options; 8 | 9 | this.locale = locale.getObject(); 10 | 11 | this 12 | .setFormat(format) 13 | .onTick(onTick); 14 | } 15 | 16 | getFormat() { 17 | return this.format; 18 | } 19 | 20 | setFormat(value) { 21 | if (typeof value !== 'string') { 22 | throw new Error(`Clock.format is supposed to be a string, ${typeof value} given.`); 23 | } 24 | 25 | // @see https://date-fns.org/docs/format 26 | this.format = value; 27 | 28 | return this; 29 | } 30 | 31 | onTick(fn) { 32 | this.now = new Date(); 33 | 34 | fn(this); 35 | 36 | if (this.#tickId === null) { 37 | this.#tickId = setInterval(() => this.onTick(fn), 1000); 38 | } 39 | } 40 | 41 | toString() { 42 | const { locale } = this; 43 | 44 | try { 45 | return datefns.format( 46 | this.now, 47 | this.getFormat(), 48 | { locale }, 49 | ); 50 | } catch (error) { 51 | return '#invalid format#'; 52 | } 53 | } 54 | } 55 | 56 | module.exports = Clock; 57 | -------------------------------------------------------------------------------- /app/components/Locale.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | const locales = { 3 | date: require('date-fns/locale'), 4 | app: require('../locales'), 5 | }; 6 | 7 | class Locale { 8 | constructor(options = {}) { 9 | const { preferred, logger } = options; 10 | const [language, extension] = String(preferred).split('-'); 11 | 12 | logger.debug(`Preferred locale is “${preferred}”.`); 13 | 14 | const fullSupport = `${language}${extension}`; 15 | const partialSupport = language; 16 | const fallback = 'en-US'; 17 | 18 | // Find locale that is supported by date-fns. Go from best to worst. 19 | this.locale = [fullSupport, partialSupport, fallback].find((k) => locales.date[k]); 20 | 21 | // Pick datefns locale object. 22 | this.localeObject = locales.date[this.locale]; 23 | 24 | // Pick app translations. For now we just support language specific translations, 25 | // not regional specific ones. Might come at a later time. 26 | this.translations = locales.app[partialSupport] || locales.app.en; 27 | 28 | logger.debug(`Using “${this.translations.locale}” as application locale and “${this.locale}” as clock/calendar locale.`); 29 | } 30 | 31 | get() { 32 | return this.locale; 33 | } 34 | 35 | getObject() { 36 | return this.localeObject; 37 | } 38 | 39 | translate(key) { 40 | return key 41 | .split('.') 42 | .reduce((o, i) => (o || {})[i], this.translations) || key; 43 | } 44 | } 45 | 46 | module.exports = Locale; 47 | -------------------------------------------------------------------------------- /app/components/Logger.js: -------------------------------------------------------------------------------- 1 | const { writeFile } = require('fs').promises; 2 | const { logFile } = require('../paths'); 3 | 4 | class Logger { 5 | constructor() { 6 | this.filePath = logFile; 7 | this.levels = { 8 | emergency: 0, 9 | alert: 1, 10 | critical: 2, 11 | error: 3, 12 | warning: 4, 13 | notice: 5, 14 | informational: 6, 15 | debug: 7, 16 | }; 17 | 18 | this.cleanUp = writeFile(this.filePath, '\n'); 19 | } 20 | 21 | async log(level, message) { 22 | const severity = Object.keys(this.levels).find((key) => this.levels[key] === level); 23 | const entry = `[${severity}]: ${message}`; 24 | 25 | await this.cleanUp; 26 | 27 | writeFile(this.filePath, `${entry}\n`, { flag: 'a' }); 28 | } 29 | 30 | emergency(message) { 31 | return this.log(this.levels.emergency, message); 32 | } 33 | 34 | alert(message) { 35 | return this.log(this.levels.alert, message); 36 | } 37 | 38 | critical(message) { 39 | return this.log(this.levels.critical, message); 40 | } 41 | 42 | error(message) { 43 | return this.log(this.levels.error, message); 44 | } 45 | 46 | warning(message) { 47 | return this.log(this.levels.warning, message); 48 | } 49 | 50 | notice(message) { 51 | return this.log(this.levels.notice, message); 52 | } 53 | 54 | informational(message) { 55 | return this.log(this.levels.informational, message); 56 | } 57 | 58 | debug(message) { 59 | return this.log(this.levels.debug, message); 60 | } 61 | } 62 | 63 | module.exports = Logger; 64 | -------------------------------------------------------------------------------- /app/components/Preferences.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron'); 2 | const { resolve } = require('path'); 3 | 4 | const { 5 | readFile, 6 | writeFile, 7 | readdir, 8 | mkdir, 9 | } = require('fs').promises; 10 | 11 | const { 12 | preferencesFile, 13 | customBackgroundsDirectory, 14 | integratedBackgroundsDirectory, 15 | } = require('../paths'); 16 | 17 | const Window = require('./Window'); 18 | 19 | const { 20 | PREFERENCES_GET, 21 | PREFERENCES_GET_ALL, 22 | PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, 23 | PREFERENCES_GET_BACKGROUNDS, 24 | PREFERENCES_HIDE, 25 | PREFERENCES_SET, 26 | PREFERENCES_SHOW, 27 | 28 | } = require('../views/preferences/ipc'); 29 | 30 | class Preferences { 31 | constructor(options = {}) { 32 | const { 33 | onChange, 34 | defaults, 35 | logger, 36 | } = options; 37 | 38 | this.logger = logger; 39 | this.filePath = preferencesFile; 40 | this.onChange = onChange || (() => {}); 41 | this.data = new Map(Object.entries(defaults)); 42 | 43 | ipcMain.handle(PREFERENCES_GET, (event, key) => this.get(key)); 44 | ipcMain.handle(PREFERENCES_GET_ALL, () => this.getAll()); 45 | ipcMain.handle(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, this.getBackgroundFileContents.bind(this)); 46 | ipcMain.handle(PREFERENCES_GET_BACKGROUNDS, this.getBackgrounds.bind(this)); 47 | 48 | ipcMain.on(PREFERENCES_SET, (event, key, value) => this.set(key, value)); 49 | ipcMain.on(PREFERENCES_HIDE, () => this.window.hide()); 50 | ipcMain.on(PREFERENCES_SHOW, () => this.window.show()); 51 | 52 | this.window = new Window({ 53 | name: 'preferences', 54 | titleBarStyle: 'hidden', 55 | transparent: true, 56 | vibrancy: 'sidebar', 57 | trafficLightPosition: { x: 20, y: 20 }, 58 | sourceFile: resolve(__dirname, '../views/preferences/preferences.html'), 59 | webPreferences: { 60 | preload: resolve(__dirname, '../views/preferences/preload.js'), 61 | }, 62 | }); 63 | 64 | this.logger.debug('Preferences module created.'); 65 | this.load(); 66 | } 67 | 68 | async load() { 69 | try { 70 | this.logger.debug(`Trying to load user preferences from “${this.filePath}”.`); 71 | 72 | Object 73 | .entries(JSON.parse(await readFile(this.filePath, 'utf8'))) 74 | .forEach((item) => this.set(...item, false)); 75 | 76 | await mkdir(customBackgroundsDirectory); 77 | } catch ({ message }) { 78 | if (/enoent/i.test(message)) { 79 | this.logger.debug('Looks like it’s the first time starting Timestamp. No user preferences found.'); 80 | } else if (/eexist/i.test(message)) { 81 | this.logger.debug('Directory for custom backgrounds has already been created.'); 82 | } else { 83 | this.logger.error(message); 84 | } 85 | } 86 | 87 | return this; 88 | } 89 | 90 | async save() { 91 | await writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.data))); 92 | 93 | return this; 94 | } 95 | 96 | getAll() { 97 | return new Map(this.data); 98 | } 99 | 100 | async getBackgroundFileContents(event, filePath) { 101 | try { 102 | return await readFile(filePath, { encoding: 'utf-8' }); 103 | } catch ({ message }) { 104 | if (/enoent/i.test(message)) { 105 | this.logger.warning(`Couldn’t find background file “${filePath}”.`); 106 | } else { 107 | this.logger.error(message); 108 | } 109 | } 110 | 111 | return ''; 112 | } 113 | 114 | async getBackgrounds() { 115 | const backgrounds = []; 116 | const directories = [integratedBackgroundsDirectory, customBackgroundsDirectory]; 117 | 118 | await Promise.all( 119 | directories.map(async (directory) => { 120 | try { 121 | (await readdir(directory)).forEach( 122 | (background) => backgrounds.push(resolve(directory, background)), 123 | ); 124 | } catch ({ message }) { 125 | this.logger.warn(message); 126 | } 127 | }), 128 | ); 129 | 130 | return backgrounds; 131 | } 132 | 133 | get(key) { 134 | return this.data.get(key); 135 | } 136 | 137 | set(key, value, persist = true) { 138 | this.logger.debug(`Setting value for preference with key ”${key}” to “${value}”.`); 139 | 140 | this.data.set(key, value); 141 | this.onChange(key, value); 142 | 143 | if (persist) { 144 | this.save(); 145 | } 146 | 147 | return this; 148 | } 149 | } 150 | 151 | module.exports = Preferences; 152 | -------------------------------------------------------------------------------- /app/components/SystemTray.js: -------------------------------------------------------------------------------- 1 | const { Tray, nativeImage } = require('electron'); 2 | 3 | class SystemTray { 4 | constructor(options = {}) { 5 | const { onClick, logger } = options; 6 | 7 | this.logger = logger; 8 | this.prefix = ''; 9 | this.tray = new Tray( 10 | nativeImage.createEmpty(), 11 | ); 12 | 13 | this.logger.debug('System tray created.'); 14 | 15 | if (typeof onClick === 'function') { 16 | this.tray.on('click', onClick); 17 | } 18 | } 19 | 20 | getBounds() { 21 | return this.tray.getBounds(); 22 | } 23 | 24 | getPrefix() { 25 | return this.prefix; 26 | } 27 | 28 | setPrefix(value) { 29 | this.prefix = value; 30 | 31 | return this; 32 | } 33 | 34 | getLabel() { 35 | return this.tray.getTitle(); 36 | } 37 | 38 | setLabel(label) { 39 | const { tray } = this; 40 | 41 | if (tray.isDestroyed()) { 42 | this.logger.error('Unable to set label since tray is destroyed.'); 43 | 44 | return this; 45 | } 46 | 47 | tray.setTitle(`${this.getPrefix()}${this.label = label}`, { 48 | fontType: 'monospacedDigit', 49 | }); 50 | 51 | return this; 52 | } 53 | } 54 | 55 | module.exports = SystemTray; 56 | -------------------------------------------------------------------------------- /app/components/Updater.js: -------------------------------------------------------------------------------- 1 | const { lt } = require('semver'); 2 | const { get } = require('https'); 3 | const { autoUpdater } = require('electron'); 4 | 5 | class Updater { 6 | constructor(options = {}) { 7 | const { 8 | checkEvery = 1000 * 60 * 60 * 24, // 24 hours 9 | currentVersion, 10 | feedUrl, 11 | logger, 12 | onUpdateDownloaded, 13 | } = options; 14 | 15 | this.feedUrl = feedUrl; 16 | this.logger = logger; 17 | 18 | autoUpdater.on('error', this.onError.bind(this)); 19 | autoUpdater.on('update-downloaded', onUpdateDownloaded); 20 | 21 | setInterval(this.onTick.bind(this, currentVersion), checkEvery); 22 | 23 | this.logger.debug('Updater module created.'); 24 | this.logger.debug(`Checking “${feedUrl}” every ${checkEvery / 1000} seconds for updates.`); 25 | 26 | this.onTick(currentVersion); 27 | } 28 | 29 | async fetchJson() { 30 | return new Promise((resolve, reject) => { 31 | const request = get(this.feedUrl, (response) => { 32 | const { statusCode } = response; 33 | const json = []; 34 | 35 | if (statusCode !== 200) { 36 | reject(new Error(`Feed url is not reachable. Response status code is ${statusCode}.`)); 37 | } else { 38 | response.on('data', json.push.bind(json)); 39 | response.on('end', () => { 40 | try { 41 | resolve(JSON.parse(json.join())); 42 | } catch (error) { 43 | this.logger.error('Couldn’t parse feed response.'); 44 | } 45 | }); 46 | } 47 | }); 48 | 49 | request.on('error', reject); 50 | }); 51 | } 52 | 53 | quitAndInstall() { 54 | autoUpdater.quitAndInstall(); 55 | 56 | return this; 57 | } 58 | 59 | async onTick(currentVersion) { 60 | try { 61 | const { version } = await this.fetchJson(); 62 | 63 | if (lt(currentVersion, version) === false) { 64 | return; 65 | } 66 | 67 | autoUpdater.setFeedURL(this.feedUrl); 68 | autoUpdater.checkForUpdates(); 69 | 70 | this.logger.debug(`Update available. (${currentVersion} -> ${version})`); 71 | } catch ({ message }) { 72 | this.logger.error(`Update tick failed because of “${message}”.`); 73 | } 74 | } 75 | 76 | onError({ message }) { 77 | this.logger.error(`AutoUpdater failed because of “${message}”.`); 78 | 79 | return this; 80 | } 81 | } 82 | 83 | module.exports = Updater; 84 | -------------------------------------------------------------------------------- /app/components/Window.js: -------------------------------------------------------------------------------- 1 | const { BrowserWindow, shell } = require('electron'); 2 | 3 | class Window { 4 | constructor(options = {}) { 5 | const defaults = { 6 | alwaysOnTop: true, 7 | frame: false, 8 | minimizable: false, 9 | resizable: false, 10 | show: false, 11 | }; 12 | 13 | const { 14 | sourceFile, 15 | name, 16 | onReady, 17 | ...rest 18 | } = options; 19 | 20 | this.name = name; 21 | this.browserWindow = new BrowserWindow({ ...defaults, ...rest }); 22 | 23 | if (typeof onReady === 'function') { 24 | this.browserWindow.on('ready-to-show', onReady); 25 | } 26 | 27 | // @see https://www.electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation 28 | this.browserWindow.webContents.on('will-navigate', (event, navigationUrl) => { 29 | event.preventDefault(); 30 | 31 | if (/^https?:\/\//.test(navigationUrl)) { 32 | shell.openExternal(navigationUrl); 33 | } 34 | }); 35 | 36 | // @see https://www.electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows 37 | this.browserWindow.webContents.on('new-window', (event) => event.preventDefault()); 38 | 39 | this 40 | .browserWindow 41 | .on('close', this.onClose.bind(this)) 42 | .loadFile(sourceFile); 43 | } 44 | 45 | isSame(window) { 46 | return window === this.browserWindow; 47 | } 48 | 49 | destroy() { 50 | this.browserWindow.destroy(); 51 | 52 | return this; 53 | } 54 | 55 | show() { 56 | this.browserWindow.webContents.send(`${this.name}.show`); 57 | this.browserWindow.show(); 58 | 59 | return this; 60 | } 61 | 62 | hide() { 63 | this.browserWindow.webContents.send(`${this.name}.hide`); 64 | this.browserWindow.hide(); 65 | 66 | return this; 67 | } 68 | 69 | toggleVisibility() { 70 | return this.isVisible() ? this.hide() : this.show(); 71 | } 72 | 73 | isVisible() { 74 | return this.browserWindow.isVisible(); 75 | } 76 | 77 | onClose(event) { 78 | this.hide(); 79 | 80 | // By default all windows in Timestamp are hidden and not closed 81 | event.preventDefault(); 82 | } 83 | 84 | getBrowserWindow() { 85 | return this.browserWindow; 86 | } 87 | 88 | getWebContents() { 89 | return this.browserWindow.webContents; 90 | } 91 | 92 | getContentSize() { 93 | return this.browserWindow.getContentSize(); 94 | } 95 | 96 | setContentSize(width, height) { 97 | if (typeof width !== 'number' || typeof height !== 'number') { 98 | throw new Error('Window.setContentSize has been called with non-numeric arguments.'); 99 | } 100 | 101 | this.browserWindow.setContentSize(width, height, true); 102 | 103 | return this; 104 | } 105 | 106 | getPosition() { 107 | return this.browserWindow.getPosition(); 108 | } 109 | 110 | setPosition(x, y, centerToX = true) { 111 | this.browserWindow.setPosition( 112 | centerToX ? Math.round(x - (this.browserWindow.getSize()[0] / 2)) : x, 113 | y, 114 | ); 115 | 116 | return this; 117 | } 118 | } 119 | 120 | module.exports = Window; 121 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | app, screen, ipcMain, BrowserWindow, 3 | } = require('electron'); 4 | 5 | const { arch, platform, release } = require('os'); 6 | const { resolve } = require('path'); 7 | const { parseInline } = require('marked'); 8 | 9 | const Calendar = require('./components/Calendar'); 10 | const Clock = require('./components/Clock'); 11 | const Locale = require('./components/Locale'); 12 | const Logger = require('./components/Logger'); 13 | const Preferences = require('./components/Preferences'); 14 | const SystemTray = require('./components/SystemTray'); 15 | const Updater = require('./components/Updater'); 16 | const { PREFERENCES_CHANGED } = require('./views/preferences/ipc'); 17 | const { integratedBackgroundsDirectory } = require('./paths'); 18 | 19 | const { 20 | APP_IS_PACKAGED, 21 | APP_QUIT, 22 | APP_RESIZE_WINDOW, 23 | APP_RESTART, 24 | APP_TICK, 25 | APP_TRANSLATE, 26 | APP_UPDATE_DOWNLOADED, 27 | } = require('./ipc'); 28 | 29 | const defaultPreferences = { 30 | calendarBackground: resolve(integratedBackgroundsDirectory, 'gradient.svg'), 31 | calendarLegendFormat: 'MMMM y', 32 | calendarTodayFormat: 'EEEE,\ndo MMMM', 33 | clockFormat: 'PPPP', 34 | openAtLogin: false, 35 | }; 36 | 37 | (async () => { 38 | await app.whenReady(); 39 | 40 | app.dock.hide(); 41 | 42 | return new class { 43 | constructor() { 44 | const currentVersion = app.getVersion(); 45 | 46 | this.logger = new Logger(); 47 | 48 | this.logger.debug(`Starting Timestamp v${currentVersion} on “${platform()}-${arch()} v${release()}”.`); 49 | this.logger.debug(`Running in ${app.isPackaged ? 'production' : 'development'} mode.`); 50 | 51 | if (app.isPackaged) { 52 | this.updater = new Updater({ 53 | currentVersion, 54 | feedUrl: 'https://mzdr.github.io/timestamp/update.json', 55 | logger: this.logger, 56 | onUpdateDownloaded: this.onUpdateDownloaded.bind(this), 57 | }); 58 | } 59 | 60 | this.locale = new Locale({ 61 | logger: this.logger, 62 | preferred: app.getLocale(), 63 | }); 64 | 65 | this.tray = new SystemTray({ 66 | logger: this.logger, 67 | onClick: this.onTrayClicked.bind(this), 68 | }); 69 | 70 | this.clock = new Clock({ 71 | format: defaultPreferences.clockFormat, 72 | locale: this.locale, 73 | onTick: this.onTick.bind(this), 74 | }); 75 | 76 | this.calendar = new Calendar({ 77 | locale: this.locale, 78 | logger: this.logger, 79 | }); 80 | 81 | this.preferences = new Preferences({ 82 | defaults: defaultPreferences, 83 | logger: this.logger, 84 | onChange: this.onPreferencesChanged.bind(this), 85 | }); 86 | 87 | ipcMain.handle(APP_IS_PACKAGED, () => app.isPackaged); 88 | ipcMain.handle(APP_TRANSLATE, this.onTranslate.bind(this)); 89 | ipcMain.on(APP_QUIT, () => app.exit()); 90 | ipcMain.on(APP_RESIZE_WINDOW, this.onResizeWindow.bind(this)); 91 | ipcMain.on(APP_RESTART, this.onRestart.bind(this)); 92 | } 93 | 94 | onRestart() { 95 | if (this.updater === undefined) { 96 | return this; 97 | } 98 | 99 | this.calendar.window.destroy(); 100 | this.preferences.window.destroy(); 101 | this.updater.quitAndInstall(); 102 | 103 | return this; 104 | } 105 | 106 | onResizeWindow({ sender }, { width, height }) { 107 | const { calendar, preferences } = this; 108 | const window = BrowserWindow.fromWebContents(sender); 109 | 110 | [calendar, preferences] 111 | .find((view) => view.window.isSame(window)) 112 | .window 113 | .setContentSize(width, height); 114 | } 115 | 116 | onPreferencesChanged(key, value) { 117 | if (key === 'openAtLogin') { 118 | app.setLoginItemSettings({ openAtLogin: value }); 119 | } else if (key === 'clockFormat') { 120 | this.clock.setFormat(value); 121 | } else if (/^calendar/.test(key)) { 122 | this.calendar.window.getWebContents().send(PREFERENCES_CHANGED, key, value); 123 | } 124 | 125 | return this; 126 | } 127 | 128 | onTick(clock) { 129 | this.tray.setLabel(clock.toString()); 130 | this.calendar?.window.getWebContents().send(APP_TICK, clock.now); 131 | } 132 | 133 | onTranslate(event, key, options = {}) { 134 | const { markdown = false } = options; 135 | const translation = this.locale.translate(key); 136 | 137 | if (markdown) { 138 | return parseInline(translation); 139 | } 140 | 141 | return translation; 142 | } 143 | 144 | onTrayClicked() { 145 | const { calendar, tray } = this; 146 | const bounds = tray.getBounds(); 147 | const currentMousePosition = screen.getCursorScreenPoint(); 148 | const currentDisplay = screen.getDisplayNearestPoint(currentMousePosition); 149 | const yOffset = 6; 150 | 151 | // Always center calendar window relative to tray icon 152 | calendar 153 | .window 154 | .setPosition(bounds.x + (bounds.width / 2), currentDisplay.workArea.y + yOffset) 155 | .toggleVisibility(); 156 | } 157 | 158 | onUpdateDownloaded() { 159 | this.tray.setPrefix('→ '); 160 | this.preferences.window.getWebContents().send(APP_UPDATE_DOWNLOADED); 161 | this.calendar.window.getWebContents().send(APP_UPDATE_DOWNLOADED); 162 | } 163 | }(); 164 | })(); 165 | -------------------------------------------------------------------------------- /app/ipc.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | const { productName, version, copyright } = require('../package.json'); 3 | 4 | const APP_IS_PACKAGED = 'app.is-packaged'; 5 | const APP_QUIT = 'app.quit'; 6 | const APP_RESIZE_WINDOW = 'app.resize-window'; 7 | const APP_RESTART = 'app.restart'; 8 | const APP_TICK = 'app.tick'; 9 | const APP_TRANSLATE = 'app.translate'; 10 | const APP_UPDATE_DOWNLOADED = 'app.update-downloaded'; 11 | 12 | module.exports = { 13 | APP_IS_PACKAGED, 14 | APP_QUIT, 15 | APP_RESIZE_WINDOW, 16 | APP_RESTART, 17 | APP_TICK, 18 | APP_TRANSLATE, 19 | APP_UPDATE_DOWNLOADED, 20 | 21 | api: { 22 | productName, 23 | version, 24 | copyright, 25 | 26 | isPackaged: () => ipcRenderer.invoke(APP_IS_PACKAGED), 27 | on: (channel, fn) => ipcRenderer.on(`app.${channel}`, fn), 28 | quit: () => ipcRenderer.send(APP_QUIT), 29 | resizeWindow: (payload) => ipcRenderer.send(APP_RESIZE_WINDOW, payload), 30 | restart: () => ipcRenderer.send(APP_RESTART), 31 | translate: (key, options) => ipcRenderer.invoke(APP_TRANSLATE, key, options), 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /app/locales/de.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Deutsch', 3 | locale: 'de', 4 | app: { 5 | restart: 'Neustarten', 6 | updateDownloaded: 'Eine neue Version von Timestamp wurde heruntergeladen. Bitte starten Sie die App neu, um sie zu aktualisieren.', 7 | }, 8 | preferences: { 9 | category: { 10 | general: 'Generell', 11 | tray: 'System Tray', 12 | calendar: 'Kalender', 13 | shortcuts: 'Tastaturkürzel', 14 | quit: 'Beenden', 15 | }, 16 | openAtLogin: { 17 | label: 'Autostart', 18 | description: 'Aktivieren Sie diese Option, wenn Sie möchten, dass Timestamp automatisch beim Starten des Computers gestartet werden soll.', 19 | }, 20 | clockFormat: { 21 | label: 'Format der Uhr', 22 | description: 'Das [Format](https://date-fns.org/docs/format) der Uhrzeitanzeige im System-Tray.', 23 | }, 24 | calendarBackground: { 25 | label: 'Hintergrund', 26 | description: 'Wählen Sie einen Kalenderhintergrund aus der Ihrem Geschmack entspricht.', 27 | }, 28 | calendarLegendFormat: { 29 | label: 'Format der Legende', 30 | description: 'Das [Format](https://date-fns.org/docs/format) der Legende über dem Monat.', 31 | }, 32 | calendarTodayFormat: { 33 | label: 'Format des aktuellen Tages', 34 | description: 'Das [Format](https://date-fns.org/docs/format) des aktuellen Tages welches im Kalenderkopf angezeigt wird.', 35 | }, 36 | shortcuts: { 37 | description: 'Im Folgenden finden Sie eine vollständige Liste der Tastenkombinationen, die Sie im Kalenderfenster verwenden können.', 38 | keys: [ 39 | ['W', 'Wochennummern anzeigen'], 40 | ['Leertaste', 'Aktuellen Tag anzeigen'], 41 | ], 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /app/locales/en.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'English', 3 | locale: 'en', 4 | app: { 5 | restart: 'Restart', 6 | updateDownloaded: 'A new version of Timestamp has been downloaded. Please restart the app in order to update it.', 7 | }, 8 | preferences: { 9 | category: { 10 | general: 'General', 11 | tray: 'System tray', 12 | calendar: 'Calendar', 13 | shortcuts: 'Shortcuts', 14 | quit: 'Quit', 15 | }, 16 | openAtLogin: { 17 | label: 'Open at login', 18 | description: 'Enable this option if you want Timestamp to start automatically when you start your computer.', 19 | }, 20 | clockFormat: { 21 | label: 'Clock format', 22 | description: 'The format [pattern](https://date-fns.org/docs/format) of the system tray clock.', 23 | }, 24 | calendarBackground: { 25 | label: 'Background', 26 | description: 'Choose a calendar background that suits your personal liking.', 27 | }, 28 | calendarLegendFormat: { 29 | label: 'Legend format', 30 | description: 'The format [pattern](https://date-fns.org/docs/format) of the legend above the month.', 31 | }, 32 | calendarTodayFormat: { 33 | label: 'Today format', 34 | description: 'The format [pattern](https://date-fns.org/docs/format) of the today display in the calendar head.', 35 | }, 36 | shortcuts: { 37 | description: 'See below for a complete list of keyboard shortcuts that you can use in the calendar window.', 38 | keys: [ 39 | ['W', 'Toggle week numbers'], 40 | ['Space', 'Go to today'], 41 | ], 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /app/locales/index.js: -------------------------------------------------------------------------------- 1 | const de = require('./de'); 2 | const en = require('./en'); 3 | 4 | module.exports = { 5 | de, 6 | en, 7 | }; 8 | -------------------------------------------------------------------------------- /app/paths.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | const { resolve } = require('path'); 3 | 4 | const storagePath = app.getPath('userData'); 5 | 6 | module.exports = { 7 | integratedBackgroundsDirectory: resolve(__dirname, 'assets/backgrounds'), 8 | customBackgroundsDirectory: resolve(storagePath, 'Backgrounds'), 9 | logFile: resolve(storagePath, 'Output.log'), 10 | preferencesFile: resolve(storagePath, 'UserPreferences.json'), 11 | }; 12 | -------------------------------------------------------------------------------- /app/styles/components/button-primary.css: -------------------------------------------------------------------------------- 1 | .button-primary { 2 | background-blend-mode: color-burn; 3 | background-color: var(--color-brand); 4 | background-image: linear-gradient(180deg, #fff, #ccc); 5 | border-radius: 3px; 6 | border: 0; 7 | color: var(--palette-white); 8 | font-family: inherit; 9 | font-size: inherit; 10 | outline: 0; 11 | padding: 6px 12px; 12 | text-shadow: -1px 0 rgba(0, 0, 0, 0.1); 13 | } 14 | -------------------------------------------------------------------------------- /app/styles/components/container-alert.css: -------------------------------------------------------------------------------- 1 | .container-alert { 2 | align-items: center; 3 | display: grid; 4 | grid-gap: var(--grid-gap); 5 | grid-template-areas: "icon message actions"; 6 | overflow: hidden; 7 | padding: var(--grid-gap); 8 | } 9 | 10 | .container-alert > .icon { 11 | align-items: center; 12 | display: flex; 13 | font-size: calc(var(--type-size) * 2); 14 | grid-area: icon; 15 | justify-content: center; 16 | } 17 | .container-alert > .message { grid-area: message; } 18 | .container-alert > .actions { grid-area: actions; } 19 | -------------------------------------------------------------------------------- /app/styles/components/form-group.css: -------------------------------------------------------------------------------- 1 | .form-group { 2 | display: grid; 3 | grid-gap: calc(var(--grid-gap) / 3) calc(var(--grid-gap) * 3); 4 | grid-template-areas: "label action" "description action"; 5 | grid-template-columns: 1fr min-content; 6 | } 7 | 8 | .form-group > .label { 9 | align-self: center; 10 | grid-area: label; 11 | } 12 | 13 | .form-group > .description { 14 | color: var(--color-shy); 15 | font-size: var(--shy-size); 16 | grid-area: description; 17 | } 18 | 19 | .form-group > .action { 20 | align-self: flex-start; 21 | background-color: #fff; 22 | border-radius: 3px; 23 | border: 0; 24 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); 25 | font: inherit; 26 | grid-area: action; 27 | margin: 0; 28 | outline: 0; 29 | padding: 6px 12px; 30 | } 31 | 32 | .form-group > .action.-text { 33 | color: var(--palette-black); 34 | resize: none; 35 | text-align: center; 36 | } 37 | 38 | .form-group > .action.-toggle { 39 | height: 20px; 40 | width: 20px; 41 | } 42 | 43 | .form-group > .action.-select { 44 | appearance: none; 45 | text-transform: capitalize; 46 | } 47 | -------------------------------------------------------------------------------- /app/styles/components/fx-bounce.css: -------------------------------------------------------------------------------- 1 | @keyframes bounce { 2 | 0%, 20%, 53%, 80%, 100% { 3 | animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); 4 | transform: translate3d(0, 0, 0); 5 | } 6 | 7 | 40%, 43% { 8 | animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); 9 | transform: translate3d(0, -10px, 0); 10 | } 11 | 12 | 70% { 13 | animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); 14 | transform: translate3d(0, -6px, 0); 15 | } 16 | 17 | 90% { 18 | transform: translate3d(0, -2px, 0); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/styles/components/icon-dots.css: -------------------------------------------------------------------------------- 1 | .icon-dots { 2 | display: block; 3 | height: 6px; 4 | overflow: visible; 5 | } 6 | 7 | .icon-dots > .dot { 8 | fill: currentColor; 9 | opacity: 0.5; 10 | } 11 | 12 | .icon-dots > .dot:nth-of-type(2) { 13 | animation-duration: 1.25s; 14 | animation-fill-mode: both; 15 | animation-iteration-count: infinite; 16 | animation-name: bounce; 17 | animation-play-state: paused; 18 | transform-origin: center bottom; 19 | } 20 | -------------------------------------------------------------------------------- /app/styles/components/is-draggable.css: -------------------------------------------------------------------------------- 1 | .is-draggable { 2 | -webkit-app-region: drag; 3 | } 4 | 5 | .is-draggable.-not { 6 | -webkit-app-region: no-drag; 7 | } 8 | -------------------------------------------------------------------------------- /app/styles/components/is-native.css: -------------------------------------------------------------------------------- 1 | .is-native { 2 | --palette-black: #272727; 3 | --palette-white: #f5f2f2; 4 | 5 | --color-line: #dcd9da; 6 | } 7 | 8 | @media (prefers-color-scheme: dark) { 9 | .is-native { 10 | --palette-black: #322d2c; 11 | --palette-white: #e0e0df; 12 | 13 | --color-paper: var(--palette-black); 14 | --color-pen: var(--palette-white); 15 | --color-line: #434041; 16 | } 17 | } 18 | 19 | .is-native.-transparent { 20 | background-color: transparent; 21 | } 22 | -------------------------------------------------------------------------------- /app/styles/components/list-shortcuts.css: -------------------------------------------------------------------------------- 1 | .list-shortcuts { 2 | display: grid; 3 | grid-template-columns: 1fr 2fr; 4 | grid-gap: calc(var(--grid-gap) / 2) var(--grid-gap); 5 | } 6 | 7 | .list-shortcuts > .keys { 8 | align-items: center; 9 | display: grid; 10 | font-size: var(--shy-size); 11 | grid-auto-flow: column; 12 | grid-column-gap: calc(var(--grid-gap) / 2); 13 | justify-content: flex-start; 14 | } 15 | 16 | .list-shortcuts > .keys > .key { 17 | background-color: var(--palette-white); 18 | background-image: linear-gradient(to top, #e6e6e6, transparent); 19 | border-radius: 3px; 20 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); 21 | color: var(--palette-black); 22 | min-width: 1.25ch; 23 | padding: 6px 12px; 24 | text-align: center; 25 | } 26 | -------------------------------------------------------------------------------- /app/styles/meta/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --palette-blue: #008bed; 3 | --palette-green: #0cbd00; 4 | --palette-black: #111; 5 | --palette-gray-20: #333; 6 | --palette-gray-40: #666; 7 | --palette-gray-60: #999; 8 | --palette-gray-80: #ccc; 9 | --palette-white: #fff; 10 | 11 | --color-brand: var(--palette-blue); 12 | --color-paper: var(--palette-white); 13 | --color-line: var(--palette-gray-80); 14 | --color-pen: var(--palette-black); 15 | --color-shy: var(--palette-gray-60); 16 | } 17 | 18 | @media (prefers-color-scheme: dark) { 19 | :root { 20 | --palette-white: #eee; 21 | 22 | --color-paper: var(--palette-black); 23 | --color-pen: var(--palette-white); 24 | --color-shy: var(--palette-gray-40); 25 | --color-line: var(--palette-gray-20); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/styles/meta/fx.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fx-duration: 300ms; 3 | --fx-radius: 15px; 4 | } 5 | -------------------------------------------------------------------------------- /app/styles/meta/grid.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --grid-gap: 22px; 3 | } 4 | -------------------------------------------------------------------------------- /app/styles/meta/typography.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --type-family: system-ui; 3 | --type-rendering: optimizeLegibility; 4 | --type-rhythm: 1.5; 5 | --type-sentence: 60ch; 6 | --type-size: 14px; 7 | 8 | --shy-size: 12px; 9 | } 10 | -------------------------------------------------------------------------------- /app/styles/shared/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | background-color: var(--color-paper); 3 | box-sizing: border-box; 4 | color: var(--color-pen); 5 | cursor: default; 6 | } 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: inherit; 12 | } 13 | 14 | ::-webkit-scrollbar { 15 | display: none; 16 | } 17 | 18 | body { 19 | margin: 0; 20 | } 21 | 22 | a { 23 | color: var(--color-brand); 24 | } 25 | -------------------------------------------------------------------------------- /app/styles/shared/typography.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: var(--type-family); 3 | font-size: var(--type-size); 4 | line-height: var(--type-rhythm); 5 | text-rendering: var(--type-rendering); 6 | } 7 | -------------------------------------------------------------------------------- /app/styles/styles.css: -------------------------------------------------------------------------------- 1 | @import "meta/colors.css"; 2 | @import "meta/fx.css"; 3 | @import "meta/grid.css"; 4 | @import "meta/typography.css"; 5 | @import "shared/base.css"; 6 | @import "shared/typography.css"; 7 | @import "components/button-primary.css"; 8 | @import "components/container-alert.css"; 9 | @import "components/form-group.css"; 10 | @import "components/fx-bounce.css"; 11 | @import "components/icon-dots.css"; 12 | @import "components/is-draggable.css"; 13 | @import "components/is-native.css"; 14 | @import "components/list-shortcuts.css"; 15 | -------------------------------------------------------------------------------- /app/views/calendar/calendar.css: -------------------------------------------------------------------------------- 1 | :root { 2 | width: 338px; 3 | } 4 | 5 | :root.update-downloaded .icon-dots > .dot:nth-of-type(2) { 6 | animation-play-state: running; 7 | } 8 | 9 | :root:not(.show-navigation) .calendar-view > .navigation { 10 | display: none; 11 | } 12 | 13 | .calendar-view { 14 | display: grid; 15 | position: relative; 16 | user-select: none; 17 | } 18 | 19 | .calendar-view > * { 20 | grid-column: 1; 21 | } 22 | 23 | .calendar-view > .head { 24 | grid-row: 1; 25 | } 26 | 27 | .calendar-view > .head > .today { 28 | display: block; 29 | font-size: calc(var(--type-size) * 2.5); 30 | font-weight: 100; 31 | padding: calc(var(--grid-gap) * 2.5) var(--grid-gap); 32 | text-align: center; 33 | white-space: pre; 34 | } 35 | 36 | .calendar-view > .head > .preferences { 37 | box-sizing: content-box; 38 | padding: 18px 12px; 39 | position: absolute; 40 | right: 12px; 41 | top: 6px; 42 | } 43 | 44 | .calendar-view > .legend { 45 | align-items: center; 46 | color: var(--color-shy); 47 | display: grid; 48 | grid-column-gap: 20px; 49 | grid-row: 2; 50 | grid-template-columns: 50px minmax(60px, max-content) 50px; 51 | justify-content: center; 52 | padding: 14px var(--grid-gap); 53 | text-align: center; 54 | white-space: pre; 55 | } 56 | 57 | .calendar-view > .legend::before, 58 | .calendar-view > .legend::after { 59 | content: ""; 60 | height: 1px; 61 | } 62 | 63 | .calendar-view > .legend::before { 64 | background-image: linear-gradient(to left, var(--color-line), transparent); 65 | } 66 | 67 | .calendar-view > .legend::after { 68 | background-image: linear-gradient(to right, var(--color-line), transparent); 69 | } 70 | 71 | .calendar-view > .days { 72 | aspect-ratio: 7 / 6; 73 | box-sizing: content-box; 74 | grid-row: 3; 75 | padding: 0 var(--grid-gap) var(--grid-gap); 76 | } 77 | 78 | .calendar-view > .navigation { 79 | align-content: center; 80 | backdrop-filter: blur(20px); 81 | background-color: rgba(255, 255, 255, 0.7); 82 | display: grid; 83 | grid-row-gap: var(--grid-gap); 84 | grid-row: 1 / 3; 85 | grid-template-columns: repeat(3, 1fr); 86 | padding: var(--grid-gap); 87 | width: 100%; 88 | } 89 | 90 | .go-to { 91 | align-items: center; 92 | background-color: transparent; 93 | border-radius: 1em; 94 | border: 0; 95 | color: currentColor; 96 | display: flex; 97 | font-family: var(--type-family); 98 | font-size: calc(var(--type-size) * 1.5); 99 | font-weight: 200; 100 | justify-content: center; 101 | line-height: calc(var(--type-rhythm) * 1.5); 102 | padding: 0; 103 | } 104 | 105 | .go-to.-current, 106 | .go-to.-year { 107 | font-weight: 400; 108 | } 109 | 110 | .go-to.-current { 111 | box-shadow: inset 0 0 0 1px currentColor; 112 | } 113 | 114 | .icon-chevron { 115 | opacity: 0.6; 116 | width: 16px; 117 | } 118 | 119 | @media (prefers-color-scheme: dark) { 120 | .calendar-view > .navigation { 121 | background-color: rgba(17, 17, 17, 0.5); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/views/calendar/calendar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |   12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/views/calendar/ipc.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | 3 | const CALENDAR_GET_CALENDAR = 'calendar.get-calendar'; 4 | const CALENDAR_GET_DATE = 'calendar.get-date'; 5 | const CALENDAR_GET_WEEKDAYS = 'calendar.get-weekdays'; 6 | const CALENDAR_HIDE = 'calendar.hide'; 7 | const CALENDAR_SHOW = 'calendar.show'; 8 | 9 | module.exports = { 10 | CALENDAR_GET_CALENDAR, 11 | CALENDAR_GET_DATE, 12 | CALENDAR_GET_WEEKDAYS, 13 | CALENDAR_HIDE, 14 | CALENDAR_SHOW, 15 | 16 | api: { 17 | getCalendar: (payload) => ipcRenderer.invoke(CALENDAR_GET_CALENDAR, payload), 18 | getDate: (payload) => ipcRenderer.invoke(CALENDAR_GET_DATE, payload), 19 | getWeekdays: () => ipcRenderer.invoke(CALENDAR_GET_WEEKDAYS), 20 | hide: () => ipcRenderer.send(CALENDAR_HIDE), 21 | on: (channel, fn) => ipcRenderer.on(`calendar.${channel}`, fn), 22 | show: () => ipcRenderer.send(CALENDAR_SHOW), 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /app/views/calendar/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge } = require('electron'); 2 | 3 | const { api: app } = require('../../ipc'); 4 | const { api: preferences } = require('../preferences/ipc'); 5 | const { api: calendar } = require('./ipc'); 6 | 7 | contextBridge.exposeInMainWorld('app', app); 8 | contextBridge.exposeInMainWorld('calendar', calendar); 9 | contextBridge.exposeInMainWorld('preferences', preferences); 10 | -------------------------------------------------------------------------------- /app/views/calendar/renderer.js: -------------------------------------------------------------------------------- 1 | import { 2 | bindAttributes, 3 | bindEventListeners, 4 | Calendar, 5 | define, 6 | findReferences, 7 | } from '@browserkids/web-components'; 8 | 9 | define(Calendar); 10 | 11 | window.renderer = new class Renderer { 12 | #isPackaged = null; 13 | 14 | #resize = null; 15 | 16 | constructor({ app, calendar, preferences }) { 17 | this.$root = document.documentElement; 18 | this.$refs = findReferences(this.$root); 19 | 20 | this.app = app; 21 | this.calendar = calendar; 22 | this.preferences = preferences; 23 | 24 | this.data = bindAttributes(this.$root, { 25 | hour: '', 26 | legend: '', 27 | month: '', 28 | rootClasses: [], 29 | source: '', 30 | today: '', 31 | }); 32 | 33 | bindEventListeners(this.$root, this); 34 | 35 | this.app.on('tick', this.onTick.bind(this)); 36 | this.app.on('update-downloaded', this.onUpdateDownloaded.bind(this)); 37 | this.calendar.on('hide', this.onHide.bind(this)); 38 | this.preferences.on('changed', this.onPreferencesChanged.bind(this)); 39 | 40 | this 41 | .createObserver({ resize: true }) 42 | .render(); 43 | } 44 | 45 | createObserver(settings = {}) { 46 | const { resize = false } = settings; 47 | 48 | if (resize) { 49 | this.#resize = new ResizeObserver(this.onResize.bind(this)); 50 | this.#resize.observe(this.$root); 51 | } 52 | 53 | return this; 54 | } 55 | 56 | goPreviousYear() { 57 | return this; 58 | } 59 | 60 | goNextYear() { 61 | return this; 62 | } 63 | 64 | goMonth() { 65 | return this; 66 | } 67 | 68 | onLegendClicked() { 69 | return this; 70 | } 71 | 72 | onCalendarUpdate() { 73 | return this; 74 | } 75 | 76 | onPreferencesChanged(event, key) { 77 | if (key === 'calendarTodayFormat') { 78 | this.setToday(); 79 | } else if (key === 'calendarBackground') { 80 | this.setHeadBackground(); 81 | } 82 | } 83 | 84 | onHide() { 85 | this.onTodayClicked(); 86 | } 87 | 88 | async onKeyDown(event) { 89 | const { key, metaKey } = event; 90 | 91 | if (key === 'Escape') { 92 | this.calendar.hide(); 93 | } else if (key === ',' && metaKey) { 94 | this.preferences.show(); 95 | } else if (key === 'q' && metaKey) { 96 | this.app.quit(); 97 | } else if (key === ' ') { 98 | this.onTodayClicked(); 99 | } 100 | 101 | // In general prevent any default browser shortcuts in production 102 | if (await this.isPackaged) { 103 | event.preventDefault(); 104 | } 105 | } 106 | 107 | onResize() { 108 | this.app.resizeWindow({ 109 | height: this.$root.offsetHeight, 110 | width: this.$root.offsetWidth, 111 | }); 112 | } 113 | 114 | onShowPreferences() { 115 | this.preferences.show(); 116 | } 117 | 118 | onTick(event, now) { 119 | Object.assign(this.data, { 120 | hour: now.getHours(), 121 | month: now.getMonth(), 122 | }); 123 | 124 | this.setToday(); 125 | } 126 | 127 | onTodayClicked() { 128 | return this; 129 | } 130 | 131 | onUpdateDownloaded() { 132 | this.data.rootClasses = ['update-downloaded']; 133 | } 134 | 135 | async setHeadBackground() { 136 | this.data.source = await this.preferences.getBackgroundFileContents( 137 | await this.preferences.get('calendarBackground'), 138 | ); 139 | } 140 | 141 | async setToday() { 142 | const format = await this.preferences.get('calendarTodayFormat'); 143 | const today = await this.calendar.getDate({ format: format.replace(/\n/g, '\'
\'') }); 144 | 145 | if (today === this.data.today) { 146 | return; 147 | } 148 | 149 | this.data.today = today; 150 | } 151 | 152 | render() { 153 | this.setToday(); 154 | this.setHeadBackground(); 155 | } 156 | 157 | get isPackaged() { 158 | return (async () => { 159 | if (this.#isPackaged === null) { 160 | this.#isPackaged = await this.app.isPackaged(); 161 | } 162 | 163 | return this.#isPackaged; 164 | })(); 165 | } 166 | }(window); 167 | -------------------------------------------------------------------------------- /app/views/common/dynamic-image.js: -------------------------------------------------------------------------------- 1 | import { define } from '@browserkids/web-components'; 2 | 3 | define(class DynamicImage extends HTMLElement { 4 | template = ` 5 | 23 | 24 |
25 |
26 |
27 | 28 |
29 |
30 | `; 31 | 32 | data = { 33 | source: '', 34 | }; 35 | 36 | set source(value) { 37 | if (this.data.source === value) { 38 | return; 39 | } 40 | 41 | this.data.source = value; 42 | } 43 | 44 | get source() { 45 | return this.data.source; 46 | } 47 | 48 | get hour() { 49 | if (this.hasAttribute('hour')) { 50 | return parseInt(this.getAttribute('hour'), 10); 51 | } 52 | 53 | return null; 54 | } 55 | 56 | set hour(value) { 57 | this.setAttribute('hour', value); 58 | } 59 | 60 | get month() { 61 | if (this.hasAttribute('month')) { 62 | return parseInt(this.getAttribute('month'), 10); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | set month(value) { 69 | this.setAttribute('month', value); 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /app/views/common/translation-key.js: -------------------------------------------------------------------------------- 1 | customElements.define('translation-key', class TranslationKey extends HTMLElement { 2 | #key; 3 | 4 | constructor() { 5 | super(); 6 | 7 | this.#key = this.textContent; 8 | this.render(); 9 | } 10 | 11 | async render() { 12 | const { app } = window; 13 | 14 | if (this.hasAttribute('markdown')) { 15 | this.innerHTML = await app?.translate(this.#key, { markdown: true }); 16 | } else { 17 | this.textContent = await app?.translate(this.#key); 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /app/views/preferences/ipc.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | 3 | const PREFERENCES_CHANGED = 'preferences.changed'; 4 | const PREFERENCES_GET = 'preferences.get'; 5 | const PREFERENCES_GET_ALL = 'preferences.getAll'; 6 | const PREFERENCES_GET_BACKGROUND_FILE_CONTENTS = 'preferences.get-background-file-contents'; 7 | const PREFERENCES_GET_BACKGROUNDS = 'preferences.get-backgrounds'; 8 | const PREFERENCES_HIDE = 'preferences.hide'; 9 | const PREFERENCES_SET = 'preferences.set'; 10 | const PREFERENCES_SHOW = 'preferences.show'; 11 | 12 | module.exports = { 13 | PREFERENCES_CHANGED, 14 | PREFERENCES_GET, 15 | PREFERENCES_GET_ALL, 16 | PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, 17 | PREFERENCES_GET_BACKGROUNDS, 18 | PREFERENCES_HIDE, 19 | PREFERENCES_SET, 20 | PREFERENCES_SHOW, 21 | 22 | api: { 23 | get: (key) => ipcRenderer.invoke(PREFERENCES_GET, key), 24 | getAll: () => ipcRenderer.invoke(PREFERENCES_GET_ALL), 25 | getBackgroundFileContents: (payload) => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, payload), 26 | getBackgrounds: () => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUNDS), 27 | hide: () => ipcRenderer.send(PREFERENCES_HIDE), 28 | on: (channel, fn) => ipcRenderer.on(`preferences.${channel}`, fn), 29 | set: (key, value) => ipcRenderer.send(PREFERENCES_SET, key, value), 30 | show: () => ipcRenderer.send(PREFERENCES_SHOW), 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /app/views/preferences/preferences.css: -------------------------------------------------------------------------------- 1 | :root { 2 | user-select: none; 3 | width: 720px; 4 | } 5 | 6 | .preferences-view { 7 | display: grid; 8 | grid-template-areas: "side alert" "side contents"; 9 | grid-template-columns: 192px 1fr; 10 | grid-template-rows: min-content 1fr; 11 | } 12 | 13 | .preferences-view > .side { grid-area: side; } 14 | .preferences-view > .alert { grid-area: alert; } 15 | .preferences-view > .contents { grid-area: contents; } 16 | 17 | .preferences-view > .alert, 18 | .preferences-view > .contents { 19 | background-color: var(--color-paper); 20 | } 21 | 22 | .preferences-view > .alert { 23 | border-bottom: 1px solid var(--color-line); 24 | } 25 | 26 | .preferences-view > .alert.-hidden { 27 | display: none; 28 | } 29 | 30 | .preferences-side { 31 | box-shadow: inset -1px 0 0 var(--color-line); 32 | display: grid; 33 | grid-template-rows: repeat(3, min-content); 34 | padding: var(--grid-gap); 35 | } 36 | 37 | .preferences-side > .logo { 38 | margin: calc(var(--grid-gap) * 2) auto var(--grid-gap); 39 | width: 128px; 40 | } 41 | 42 | .preferences-side > .name { 43 | font-size: calc(var(--type-size) * 1.5); 44 | font-weight: 300; 45 | margin: 0; 46 | text-align: center; 47 | } 48 | 49 | .preferences-side > .navigation { 50 | margin: calc(var(--grid-gap) * 2) 0; 51 | } 52 | 53 | .preferences-side > .about { 54 | color: var(--color-shy); 55 | font-size: var(--shy-size); 56 | text-align: center; 57 | } 58 | 59 | .preferences-navigation { 60 | display: grid; 61 | grid-row-gap: calc(var(--grid-gap) / 3); 62 | } 63 | 64 | .preferences-navigation > .item { 65 | align-items: center; 66 | appearance: none; 67 | background: transparent; 68 | border: 0; 69 | color: currentColor; 70 | display: grid; 71 | font: inherit; 72 | grid-column-gap: calc(var(--grid-gap) / 2); 73 | grid-template-columns: min-content 1fr; 74 | outline: 0; 75 | padding: 4px 8px; 76 | position: relative; 77 | text-align: left; 78 | } 79 | 80 | .preferences-navigation > .item.-active::before { 81 | background-color: var(--color-pen); 82 | border-radius: 5px; 83 | content: ""; 84 | height: 100%; 85 | left: 0; 86 | opacity: 0.14; 87 | position: absolute; 88 | top: 0; 89 | width: 100%; 90 | z-index: -1; 91 | } 92 | 93 | .preferences-navigation > .item > .icon { 94 | color: currentColor; 95 | height: 15px; 96 | opacity: 0.5; 97 | stroke-width: 1.5; 98 | width: 15px; 99 | } 100 | 101 | .preferences-contents { 102 | display: grid; 103 | } 104 | 105 | .preferences-contents > .content { 106 | align-content: flex-start; 107 | display: grid; 108 | grid-area: 1 / 1 / -1 / -1; 109 | grid-row-gap: var(--grid-gap); 110 | padding: calc(var(--grid-gap) * 2); 111 | } 112 | 113 | .preferences-contents > .content:not(.-active) { 114 | display: none; 115 | } 116 | 117 | .preferences-contents > .content > .description { 118 | color: var(--color-shy); 119 | } 120 | -------------------------------------------------------------------------------- /app/views/preferences/preferences.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 38 | 39 |
40 | 🎉 41 | app.updateDownloaded 42 |
43 | 46 |
47 |
48 | 49 |
50 |
51 | 56 |
57 |
58 | 63 |
64 |
65 | 70 | 75 | 80 |
81 |
82 | preferences.shortcuts.description 83 |
84 |
85 |
86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /app/views/preferences/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge } = require('electron'); 2 | 3 | const { api: app } = require('../../ipc'); 4 | const { api: preferences } = require('./ipc'); 5 | 6 | contextBridge.exposeInMainWorld('app', app); 7 | contextBridge.exposeInMainWorld('preferences', preferences); 8 | -------------------------------------------------------------------------------- /app/views/preferences/renderer.js: -------------------------------------------------------------------------------- 1 | import { bindAttributes, bindEventListeners, findReferences } from '@browserkids/web-components'; 2 | 3 | window.renderer = new class Renderer { 4 | #isPackaged = null; 5 | 6 | #resize = null; 7 | 8 | constructor({ app, preferences }) { 9 | this.$root = document.documentElement; 10 | this.$refs = findReferences(this.$root); 11 | this.app = app; 12 | this.preferences = preferences; 13 | 14 | bindEventListeners(this.$root, this); 15 | 16 | bindAttributes(this.$root, { 17 | productName: this.app.productName, 18 | version: this.app.version, 19 | }); 20 | 21 | this.app.on('update-downloaded', this.onUpdateDownloaded.bind(this)); 22 | 23 | this 24 | .createObserver({ resize: true }) 25 | .render(); 26 | } 27 | 28 | createObserver(settings = {}) { 29 | const { resize = false } = settings; 30 | 31 | if (resize) { 32 | this.#resize = new ResizeObserver(this.onResize.bind(this)); 33 | this.#resize.observe(this.$root); 34 | } 35 | 36 | return this; 37 | } 38 | 39 | onCategoryClicked({ currentTarget }) { 40 | const { $content, $tab } = this.$refs; 41 | const index = $tab.indexOf(currentTarget); 42 | const toggleActive = ($el, position) => $el.classList.toggle('-active', index === position); 43 | 44 | [$tab, $content].forEach(($el) => $el.forEach(toggleActive)); 45 | } 46 | 47 | onInput({ target }) { 48 | const { 49 | name, 50 | value, 51 | type, 52 | checked, 53 | } = target; 54 | 55 | const isBoolean = ['on', 'off'].indexOf(value) >= 0 && ['checkbox', 'radio'].indexOf(type) >= 0; 56 | 57 | this.preferences.set(name, isBoolean ? checked : value); 58 | } 59 | 60 | async onKeyDown(event) { 61 | const { key } = event; 62 | 63 | if (key === 'Escape') { 64 | this.preferences.hide(); 65 | } 66 | 67 | // In general prevent any default browser shortcuts in production 68 | if (await this.isPackaged) { 69 | event.preventDefault(); 70 | } 71 | } 72 | 73 | onQuitClicked() { 74 | this.app.quit(); 75 | } 76 | 77 | onResize() { 78 | this.app.resizeWindow({ 79 | height: this.$root.offsetHeight, 80 | width: this.$root.offsetWidth, 81 | }); 82 | } 83 | 84 | onRestartClicked() { 85 | this.app.restart(); 86 | } 87 | 88 | onUpdateDownloaded() { 89 | this.$refs.$alert.classList.remove('-hidden'); 90 | } 91 | 92 | async render() { 93 | const { 94 | $backgrounds, 95 | $keys, 96 | $form, 97 | $tab, 98 | } = this.$refs; 99 | 100 | const all = await this.preferences.getAll(); 101 | const backgrounds = await this.preferences.getBackgrounds(); 102 | const shortcuts = await this.app.translate('preferences.shortcuts.keys'); 103 | 104 | backgrounds 105 | .map((background) => ([background, background.split('/').pop().split('.').shift()])) 106 | .map(([value, name]) => ``) 107 | .forEach((background) => $backgrounds.insertAdjacentHTML('beforeend', background)); 108 | 109 | shortcuts 110 | .map(([keys, label]) => ([keys.split('+').map((key) => `${key}`).join('+'), label])) 111 | .map(([keys, label]) => `
${keys}
${label}
`) 112 | .forEach((shortcut) => $keys.insertAdjacentHTML('beforeend', shortcut)); 113 | 114 | Array 115 | .from(all) 116 | .filter(([key]) => $form[key]) 117 | .forEach(([key, value]) => { 118 | $form[key][typeof value === 'boolean' ? 'checked' : 'value'] = value; 119 | }); 120 | 121 | this.onCategoryClicked({ currentTarget: $tab[0] }); 122 | } 123 | 124 | get isPackaged() { 125 | return (async () => { 126 | if (this.#isPackaged === null) { 127 | this.#isPackaged = await this.app.isPackaged(); 128 | } 129 | 130 | return this.#isPackaged; 131 | })(); 132 | } 133 | }(window); 134 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mzdr/timestamp/c881044cda49b7fef4b91ef3ffa20e6bdb32a2d3/build/icon.icns -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timestamp", 3 | "productName": "Timestamp", 4 | "description": "A better macOS menu bar clock with a customizable date/time display and a calendar.", 5 | "version": "1.1.0", 6 | "author": "Sebastian Prein ", 7 | "copyright": "© 2021 Sebastian Prein", 8 | "homepage": "https://github.com/mzdr/timestamp", 9 | "license": "MIT", 10 | "main": "app/index.js", 11 | "keywords": [ 12 | "calendar", 13 | "clock", 14 | "customizable", 15 | "date", 16 | "electron", 17 | "macos", 18 | "menubar", 19 | "time", 20 | "timestamp" 21 | ], 22 | "build": { 23 | "appId": "com.mzdr.timestamp", 24 | "files": [ 25 | "app/**/*", 26 | "node_modules/**/*", 27 | "package.json" 28 | ], 29 | "mac": { 30 | "category": "public.app-category.utilities", 31 | "hardenedRuntime": true, 32 | "gatekeeperAssess": false, 33 | "entitlements": "build/entitlements.mac.plist", 34 | "entitlementsInherit": "build/entitlements.mac.plist", 35 | "darkModeSupport": true 36 | }, 37 | "afterSign": "scripts/notarize.js" 38 | }, 39 | "private": true, 40 | "scripts": { 41 | "build": "electron-builder", 42 | "clean": "rimraf dist", 43 | "lint": "eslint ./app", 44 | "lint:fix": "npm run lint -- --fix", 45 | "prebuild": "npm run lint && npm run clean", 46 | "start": "electron ./app --enable-logging", 47 | "test": "npm run lint" 48 | }, 49 | "dependencies": { 50 | "@browserkids/web-components": "^0.8.0", 51 | "date-fns": "^2.28.0", 52 | "marked": "^4.0.8", 53 | "semver": "^7.3.5" 54 | }, 55 | "devDependencies": { 56 | "dotenv": "^10.0.0", 57 | "electron": "^16.0.5", 58 | "electron-builder": "^22.14.5", 59 | "electron-notarize": "^1.1.1", 60 | "eslint": "^8.6.0", 61 | "eslint-config-airbnb-base": "^15.0.0", 62 | "eslint-plugin-import": "^2.25.3", 63 | "rimraf": "^3.0.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | require('dotenv').config(); 3 | 4 | // eslint-disable-next-line import/no-extraneous-dependencies 5 | const { notarize } = require('electron-notarize'); 6 | const { build } = require('../package.json'); 7 | 8 | exports.default = async function notarizing(context) { 9 | const { electronPlatformName, appOutDir, packager } = context; 10 | const isUnpacked = process.argv.includes('--dir'); 11 | const isMacOs = electronPlatformName === 'darwin'; 12 | 13 | if (isUnpacked || isMacOs === false) { 14 | return; 15 | } 16 | 17 | const appBundleId = build.appId; 18 | const appPath = `${appOutDir}/${packager.appInfo.productFilename}.app`; 19 | const appleId = process.env.APPLE_ID; 20 | const appleIdPassword = process.env.APPLE_ID_PASSWORD; 21 | 22 | await notarize({ 23 | appBundleId, 24 | appPath, 25 | appleId, 26 | appleIdPassword, 27 | }); 28 | }; 29 | --------------------------------------------------------------------------------