Skip to content

Commit

Permalink
[GH-314] Export native app user settings on change (#380)
Browse files Browse the repository at this point in the history
* [GH-314] Export native app user settings on change

This switches from exporting the native app user settings on window close to exporting
on settings change. This way the settings export remains independent of native application
life-cycle events.

This is a stop-gap towards enabling settings export on the native Linux app. The latter
does not have an easy way to catch window close events.

Relates to: #314

* Disable no-shadow rule to prevent false-positive

* Verify allowed localStorage keys

* Fix import order/spacing

* Treat JSON parsing errors as failed import

* Read known keys from the correct type 🤦

* Extend logging with imported keys and always include _current_ user settings

* Fixing eslint

Co-authored-by: Hossein <[email protected]>
Co-authored-by: Jesús Espino <[email protected]>
  • Loading branch information
3 people committed Aug 15, 2021
1 parent f983a59 commit b6d32da
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 74 deletions.
55 changes: 20 additions & 35 deletions mac/Focalboard/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,6 @@ class ViewController:
self.view.window?.makeFirstResponder(self.webView)
}

override func viewWillDisappear() {
super.viewWillDisappear()
persistUserSettings()
}

override var representedObject: Any? {
didSet {
// Update the view, if already loaded.
Expand Down Expand Up @@ -77,34 +72,6 @@ class ViewController:
}
}

private func persistUserSettings() {
let semaphore = DispatchSemaphore(value: 0)

webView.evaluateJavaScript("Focalboard.exportUserSettingsBlob();") { result, error in
defer { semaphore.signal() }
guard let blob = result as? String else {
NSLog("Failed to export user settings: \(error?.localizedDescription ?? "?")")
return
}
UserDefaults.standard.set(blob, forKey: "localStorage")
NSLog("Persisted user settings: \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)")
}

// During shutdown the system grants us about 5 seconds to clean up and store user data
let timeout = DispatchTime.now() + .seconds(3)
var result: DispatchTimeoutResult?

// Busy wait because evaluateJavaScript can only be called from *and* signals on the main thread
while (result != .success && .now() < timeout) {
result = semaphore.wait(timeout: .now())
RunLoop.current.run(mode: .default, before: Date())
}

if result == .timedOut {
NSLog("Timed out trying to persist user settings")
}
}

private func updateSessionTokenAndUserSettings() {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
let sessionTokenScript = WKUserScript(
Expand Down Expand Up @@ -276,11 +243,29 @@ class ViewController:
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: String], let type = body["type"], let blob = body["settingsBlob"] else {
guard
let body = message.body as? [AnyHashable: Any],
let type = body["type"] as? String,
let blob = body["settingsBlob"] as? String
else {
NSLog("Received unexpected script message \(message.body)")
return
}
NSLog("Received script message \(type): \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)")
NSLog("Received script message \(type)")
switch type {
case "didImportUserSettings":
NSLog("Imported user settings keys \(body["keys"] ?? "?")")
case "didNotImportUserSettings":
break
case "didChangeUserSettings":
UserDefaults.standard.set(blob, forKey: "localStorage")
NSLog("Persisted user settings after change for key \(body["key"] ?? "?")")
default:
NSLog("Received script message of unknown type \(type)")
}
if let settings = Data(base64Encoded: blob).flatMap({ try? JSONSerialization.jsonObject(with: $0, options: []) }) {
NSLog("Current user settings: \(settings)")
}
}
}

3 changes: 0 additions & 3 deletions webapp/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,12 @@ import LoginPage from './pages/loginPage'
import RegisterPage from './pages/registerPage'
import {Utils} from './utils'
import wsClient from './wsclient'
import {importNativeAppSettings} from './nativeApp'
import {fetchMe, getLoggedIn} from './store/users'
import {getLanguage, fetchLanguage} from './store/language'
import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks'

const App = React.memo((): JSX.Element => {
importNativeAppSettings()

const language = useAppSelector<string>(getLanguage)
const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const globalError = useAppSelector<string>(getGlobalError)
Expand Down
6 changes: 4 additions & 2 deletions webapp/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import messages_tr from '../i18n/tr.json'
import messages_zhHant from '../i18n/zh_Hant.json'
import messages_zhHans from '../i18n/zh_Hans.json'

import {UserSettings} from './userSettings'

const supportedLanguages = ['de', 'fr', 'ja', 'nl', 'ru', 'es', 'oc', 'tr', 'zh-cn', 'zh-tw']

export function getMessages(lang: string): {[key: string]: string} {
Expand Down Expand Up @@ -44,7 +46,7 @@ export function getMessages(lang: string): {[key: string]: string} {
}

export function getCurrentLanguage(): string {
let lang = localStorage.getItem('language')
let lang = UserSettings.language
if (!lang) {
if (supportedLanguages.includes(navigator.language)) {
lang = navigator.language
Expand All @@ -58,5 +60,5 @@ export function getCurrentLanguage(): string {
}

export function storeLanguage(lang: string): void {
localStorage.setItem('language', lang)
UserSettings.language = lang
}
6 changes: 6 additions & 0 deletions webapp/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@
import React from 'react'
import ReactDOM from 'react-dom'
import {Provider as ReduxProvider} from 'react-redux'
import {store as emojiMartStore} from 'emoji-mart'

import App from './app'
import {initThemes} from './theme'
import {importNativeAppSettings} from './nativeApp'
import {UserSettings} from './userSettings'

import './styles/variables.scss'
import './styles/main.scss'
import './styles/labels.scss'

import store from './store'

emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting})
importNativeAppSettings()

initThemes()
ReactDOM.render(
(
Expand Down
22 changes: 9 additions & 13 deletions webapp/src/nativeApp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {importUserSettingsBlob} from './userSettings'
import {exportUserSettingsBlob, importUserSettingsBlob} from './userSettings'

declare interface INativeApp {
settingsBlob: string | null;
Expand All @@ -13,20 +13,16 @@ export function importNativeAppSettings() {
if (typeof NativeApp === 'undefined' || !NativeApp.settingsBlob) {
return
}
const success = importUserSettingsBlob(NativeApp.settingsBlob)
const messageType = success ? 'didImportUserSettings' : 'didNotImportUserSettings'
postWebKitMessage({type: messageType, settingsBlob: NativeApp.settingsBlob})
const importedKeys = importUserSettingsBlob(NativeApp.settingsBlob)
const messageType = importedKeys.length ? 'didImportUserSettings' : 'didNotImportUserSettings'
postWebKitMessage({type: messageType, settingsBlob: exportUserSettingsBlob(), keys: importedKeys})
NativeApp.settingsBlob = null
}

export function notifySettingsChanged(key: string) {
postWebKitMessage({type: 'didChangeUserSettings', settingsBlob: exportUserSettingsBlob(), key})
}

function postWebKitMessage(message: any) {
const webkit = (window as any).webkit
if (typeof webkit === 'undefined') {
return
}
const handler = webkit.messageHandlers.nativeApp
if (typeof handler === 'undefined') {
return
}
handler.postMessage(message)
(window as any).webkit?.messageHandlers.nativeApp?.postMessage(message)
}
9 changes: 5 additions & 4 deletions webapp/src/pages/boardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {updateContents} from '../store/contents'
import {updateComments} from '../store/comments'
import {initialLoad, initialReadOnlyLoad} from '../store/initialLoad'
import {useAppSelector, useAppDispatch} from '../store/hooks'
import {UserSettings} from '../userSettings'

type Props = {
readonly?: boolean
Expand Down Expand Up @@ -70,8 +71,8 @@ const BoardPage = (props: Props) => {

if (!boardId) {
// Load last viewed boardView
const lastBoardId = localStorage.getItem('lastBoardId') || undefined
const lastViewId = localStorage.getItem('lastViewId') || undefined
const lastBoardId = UserSettings.lastBoardId || undefined
const lastViewId = UserSettings.lastViewId || undefined
if (lastBoardId) {
let newPath = generatePath(match.path, {...match.params, boardId: lastBoardId})
if (lastViewId) {
Expand All @@ -90,8 +91,8 @@ const BoardPage = (props: Props) => {
return
}

localStorage.setItem('lastBoardId', boardId || '')
localStorage.setItem('lastViewId', viewId || '')
UserSettings.lastBoardId = boardId || ''
UserSettings.lastViewId = viewId || ''
dispatch(setCurrentBoard(boardId || ''))
dispatch(setCurrentView(viewId || ''))
}, [match.params.boardId, match.params.viewId, history, boardViews])
Expand Down
10 changes: 6 additions & 4 deletions webapp/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {Utils} from './utils'

let activeThemeName: string

import {UserSettings} from './userSettings'

export type Theme = {
mainBg: string,
mainFg: string,
Expand Down Expand Up @@ -111,9 +113,9 @@ export function setTheme(theme: Theme | null): Theme {
let consolidatedTheme = defaultTheme
if (theme) {
consolidatedTheme = {...defaultTheme, ...theme}
localStorage.setItem('theme', JSON.stringify(consolidatedTheme))
UserSettings.theme = JSON.stringify(consolidatedTheme)
} else {
localStorage.setItem('theme', '')
UserSettings.theme = ''
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
consolidatedTheme = {...defaultTheme, ...darkTheme}
Expand Down Expand Up @@ -205,7 +207,7 @@ function setActiveThemeName(consolidatedTheme: Theme, theme: Theme | null) {
}

export function loadTheme(): Theme {
const themeStr = localStorage.getItem('theme')
const themeStr = UserSettings.theme
if (themeStr) {
try {
const theme = JSON.parse(themeStr)
Expand All @@ -223,7 +225,7 @@ export function loadTheme(): Theme {
export function initThemes(): void {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
const changeHandler = () => {
const themeStr = localStorage.getItem('theme')
const themeStr = UserSettings.theme
if (!themeStr) {
setTheme(null)
}
Expand Down
108 changes: 95 additions & 13 deletions webapp/src/userSettings.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {notifySettingsChanged} from './nativeApp'
import {Utils} from './utils'

// eslint-disable-next-line no-shadow
enum UserSettingKey {
Language = 'language',
Theme = 'theme',
LastBoardId = 'lastBoardId',
LastViewId = 'lastViewId',
EmojiMartSkin = 'emoji-mart.skin',
EmojiMartLast = 'emoji-mart.last',
EmojiMartFrequently = 'emoji-mart.frequently',
RandomIcons = 'randomIcons'
}

export class UserSettings {
static get(key: UserSettingKey): string | null {
return localStorage.getItem(key)
}

static set(key: UserSettingKey, value: string | null) {
if (!Object.values(UserSettingKey).includes(key)) {
return
}
if (value === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, value)
}
notifySettingsChanged(key)
}

static get language(): string | null {
return UserSettings.get(UserSettingKey.Language)
}

static set language(newValue: string | null) {
UserSettings.set(UserSettingKey.Language, newValue)
}

static get theme(): string | null {
return UserSettings.get(UserSettingKey.Theme)
}

static set theme(newValue: string | null) {
UserSettings.set(UserSettingKey.Theme, newValue)
}

static get lastBoardId(): string | null {
return UserSettings.get(UserSettingKey.LastBoardId)
}

static set lastBoardId(newValue: string | null) {
UserSettings.set(UserSettingKey.LastBoardId, newValue)
}

static get lastViewId(): string | null {
return UserSettings.get(UserSettingKey.LastViewId)
}

static set lastViewId(newValue: string | null) {
UserSettings.set(UserSettingKey.LastViewId, newValue)
}

static get prefillRandomIcons(): boolean {
return localStorage.getItem('randomIcons') !== 'false'
return UserSettings.get(UserSettingKey.RandomIcons) !== 'false'
}

static set prefillRandomIcons(newValue: boolean) {
localStorage.setItem('randomIcons', JSON.stringify(newValue))
UserSettings.set(UserSettingKey.RandomIcons, JSON.stringify(newValue))
}

static getEmojiMartSetting(key: string): any {
const prefixed = `emoji-mart.${key}`
Utils.assert((Object as any).values(UserSettingKey).includes(prefixed))
const json = UserSettings.get(prefixed as UserSettingKey)
return json ? JSON.parse(json) : null
}
}

const keys = ['language', 'theme', 'lastBoardId', 'lastViewId', 'emoji-mart.last', 'emoji-mart.frequently', 'randomIcons']
static setEmojiMartSetting(key: string, value: any) {
const prefixed = `emoji-mart.${key}`
Utils.assert((Object as any).values(UserSettingKey).includes(prefixed))
UserSettings.set(prefixed as UserSettingKey, JSON.stringify(value))
}
}

export function exportUserSettingsBlob(): string {
return window.btoa(exportUserSettings())
}

function exportUserSettings(): string {
const keys = Object.values(UserSettingKey)
const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)]))
settings.timestamp = `${Date.now()}`
return JSON.stringify(settings)
}

export function importUserSettingsBlob(blob: string): boolean {
export function importUserSettingsBlob(blob: string): string[] {
return importUserSettings(window.atob(blob))
}

function importUserSettings(json: string): boolean {
function importUserSettings(json: string): string[] {
const settings = parseUserSettings(json)
if (!settings) {
return []
}
const timestamp = settings.timestamp
const lastTimestamp = localStorage.getItem('timestamp')
if (!timestamp || (lastTimestamp && Number(timestamp) <= Number(lastTimestamp))) {
return false
return []
}
const importedKeys = []
for (const [key, value] of Object.entries(settings)) {
if (value) {
localStorage.setItem(key, value as string)
} else {
localStorage.removeItem(key)
if (Object.values(UserSettingKey).includes(key as UserSettingKey)) {
if (value) {
localStorage.setItem(key, value as string)
} else {
localStorage.removeItem(key)
}
importedKeys.push(key)
}
}
return true
return importedKeys
}

function parseUserSettings(json: string): any {
try {
return JSON.parse(json)
} catch (e) {
return {}
return undefined
}
}

0 comments on commit b6d32da

Please sign in to comment.