1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-07-15 23:54:29 +02:00

[GH-314] Export native app user settings on change (#380)

* [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 <hahmadia@users.noreply.github.com>
Co-authored-by: Jesús Espino <jespinog@gmail.com>
This commit is contained in:
Johannes Marbach
2021-08-15 12:51:19 +02:00
committed by GitHub
parent f983a59973
commit b6d32da68c
8 changed files with 146 additions and 75 deletions

View File

@ -41,11 +41,6 @@ class ViewController:
self.view.window?.makeFirstResponder(self.webView) self.view.window?.makeFirstResponder(self.webView)
} }
override func viewWillDisappear() {
super.viewWillDisappear()
persistUserSettings()
}
override var representedObject: Any? { override var representedObject: Any? {
didSet { didSet {
// Update the view, if already loaded. // Update the view, if already loaded.
@ -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() { private func updateSessionTokenAndUserSettings() {
let appDelegate = NSApplication.shared.delegate as! AppDelegate let appDelegate = NSApplication.shared.delegate as! AppDelegate
let sessionTokenScript = WKUserScript( let sessionTokenScript = WKUserScript(
@ -276,11 +243,29 @@ class ViewController:
} }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 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)") NSLog("Received unexpected script message \(message.body)")
return 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)")
}
} }
} }

View File

@ -22,15 +22,12 @@ import LoginPage from './pages/loginPage'
import RegisterPage from './pages/registerPage' import RegisterPage from './pages/registerPage'
import {Utils} from './utils' import {Utils} from './utils'
import wsClient from './wsclient' import wsClient from './wsclient'
import {importNativeAppSettings} from './nativeApp'
import {fetchMe, getLoggedIn} from './store/users' import {fetchMe, getLoggedIn} from './store/users'
import {getLanguage, fetchLanguage} from './store/language' import {getLanguage, fetchLanguage} from './store/language'
import {setGlobalError, getGlobalError} from './store/globalError' import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks' import {useAppSelector, useAppDispatch} from './store/hooks'
const App = React.memo((): JSX.Element => { const App = React.memo((): JSX.Element => {
importNativeAppSettings()
const language = useAppSelector<string>(getLanguage) const language = useAppSelector<string>(getLanguage)
const loggedIn = useAppSelector<boolean|null>(getLoggedIn) const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const globalError = useAppSelector<string>(getGlobalError) const globalError = useAppSelector<string>(getGlobalError)

View File

@ -13,6 +13,8 @@ import messages_tr from '../i18n/tr.json'
import messages_zhHant from '../i18n/zh_Hant.json' import messages_zhHant from '../i18n/zh_Hant.json'
import messages_zhHans from '../i18n/zh_Hans.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'] const supportedLanguages = ['de', 'fr', 'ja', 'nl', 'ru', 'es', 'oc', 'tr', 'zh-cn', 'zh-tw']
export function getMessages(lang: string): {[key: string]: string} { export function getMessages(lang: string): {[key: string]: string} {
@ -44,7 +46,7 @@ export function getMessages(lang: string): {[key: string]: string} {
} }
export function getCurrentLanguage(): string { export function getCurrentLanguage(): string {
let lang = localStorage.getItem('language') let lang = UserSettings.language
if (!lang) { if (!lang) {
if (supportedLanguages.includes(navigator.language)) { if (supportedLanguages.includes(navigator.language)) {
lang = navigator.language lang = navigator.language
@ -58,5 +60,5 @@ export function getCurrentLanguage(): string {
} }
export function storeLanguage(lang: string): void { export function storeLanguage(lang: string): void {
localStorage.setItem('language', lang) UserSettings.language = lang
} }

View File

@ -3,9 +3,12 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import {Provider as ReduxProvider} from 'react-redux' import {Provider as ReduxProvider} from 'react-redux'
import {store as emojiMartStore} from 'emoji-mart'
import App from './app' import App from './app'
import {initThemes} from './theme' import {initThemes} from './theme'
import {importNativeAppSettings} from './nativeApp'
import {UserSettings} from './userSettings'
import './styles/variables.scss' import './styles/variables.scss'
import './styles/main.scss' import './styles/main.scss'
@ -13,6 +16,9 @@ import './styles/labels.scss'
import store from './store' import store from './store'
emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting})
importNativeAppSettings()
initThemes() initThemes()
ReactDOM.render( ReactDOM.render(
( (

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {importUserSettingsBlob} from './userSettings' import {exportUserSettingsBlob, importUserSettingsBlob} from './userSettings'
declare interface INativeApp { declare interface INativeApp {
settingsBlob: string | null; settingsBlob: string | null;
@ -13,20 +13,16 @@ export function importNativeAppSettings() {
if (typeof NativeApp === 'undefined' || !NativeApp.settingsBlob) { if (typeof NativeApp === 'undefined' || !NativeApp.settingsBlob) {
return return
} }
const success = importUserSettingsBlob(NativeApp.settingsBlob) const importedKeys = importUserSettingsBlob(NativeApp.settingsBlob)
const messageType = success ? 'didImportUserSettings' : 'didNotImportUserSettings' const messageType = importedKeys.length ? 'didImportUserSettings' : 'didNotImportUserSettings'
postWebKitMessage({type: messageType, settingsBlob: NativeApp.settingsBlob}) postWebKitMessage({type: messageType, settingsBlob: exportUserSettingsBlob(), keys: importedKeys})
NativeApp.settingsBlob = null NativeApp.settingsBlob = null
} }
export function notifySettingsChanged(key: string) {
postWebKitMessage({type: 'didChangeUserSettings', settingsBlob: exportUserSettingsBlob(), key})
}
function postWebKitMessage(message: any) { function postWebKitMessage(message: any) {
const webkit = (window as any).webkit (window as any).webkit?.messageHandlers.nativeApp?.postMessage(message)
if (typeof webkit === 'undefined') {
return
}
const handler = webkit.messageHandlers.nativeApp
if (typeof handler === 'undefined') {
return
}
handler.postMessage(message)
} }

View File

@ -26,6 +26,7 @@ import {updateContents} from '../store/contents'
import {updateComments} from '../store/comments' import {updateComments} from '../store/comments'
import {initialLoad, initialReadOnlyLoad} from '../store/initialLoad' import {initialLoad, initialReadOnlyLoad} from '../store/initialLoad'
import {useAppSelector, useAppDispatch} from '../store/hooks' import {useAppSelector, useAppDispatch} from '../store/hooks'
import {UserSettings} from '../userSettings'
type Props = { type Props = {
readonly?: boolean readonly?: boolean
@ -70,8 +71,8 @@ const BoardPage = (props: Props) => {
if (!boardId) { if (!boardId) {
// Load last viewed boardView // Load last viewed boardView
const lastBoardId = localStorage.getItem('lastBoardId') || undefined const lastBoardId = UserSettings.lastBoardId || undefined
const lastViewId = localStorage.getItem('lastViewId') || undefined const lastViewId = UserSettings.lastViewId || undefined
if (lastBoardId) { if (lastBoardId) {
let newPath = generatePath(match.path, {...match.params, boardId: lastBoardId}) let newPath = generatePath(match.path, {...match.params, boardId: lastBoardId})
if (lastViewId) { if (lastViewId) {
@ -90,8 +91,8 @@ const BoardPage = (props: Props) => {
return return
} }
localStorage.setItem('lastBoardId', boardId || '') UserSettings.lastBoardId = boardId || ''
localStorage.setItem('lastViewId', viewId || '') UserSettings.lastViewId = viewId || ''
dispatch(setCurrentBoard(boardId || '')) dispatch(setCurrentBoard(boardId || ''))
dispatch(setCurrentView(viewId || '')) dispatch(setCurrentView(viewId || ''))
}, [match.params.boardId, match.params.viewId, history, boardViews]) }, [match.params.boardId, match.params.viewId, history, boardViews])

View File

@ -9,6 +9,8 @@ import {Utils} from './utils'
let activeThemeName: string let activeThemeName: string
import {UserSettings} from './userSettings'
export type Theme = { export type Theme = {
mainBg: string, mainBg: string,
mainFg: string, mainFg: string,
@ -111,9 +113,9 @@ export function setTheme(theme: Theme | null): Theme {
let consolidatedTheme = defaultTheme let consolidatedTheme = defaultTheme
if (theme) { if (theme) {
consolidatedTheme = {...defaultTheme, ...theme} consolidatedTheme = {...defaultTheme, ...theme}
localStorage.setItem('theme', JSON.stringify(consolidatedTheme)) UserSettings.theme = JSON.stringify(consolidatedTheme)
} else { } else {
localStorage.setItem('theme', '') UserSettings.theme = ''
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)') const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) { if (darkThemeMq.matches) {
consolidatedTheme = {...defaultTheme, ...darkTheme} consolidatedTheme = {...defaultTheme, ...darkTheme}
@ -205,7 +207,7 @@ function setActiveThemeName(consolidatedTheme: Theme, theme: Theme | null) {
} }
export function loadTheme(): Theme { export function loadTheme(): Theme {
const themeStr = localStorage.getItem('theme') const themeStr = UserSettings.theme
if (themeStr) { if (themeStr) {
try { try {
const theme = JSON.parse(themeStr) const theme = JSON.parse(themeStr)
@ -223,7 +225,7 @@ export function loadTheme(): Theme {
export function initThemes(): void { export function initThemes(): void {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)') const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
const changeHandler = () => { const changeHandler = () => {
const themeStr = localStorage.getItem('theme') const themeStr = UserSettings.theme
if (!themeStr) { if (!themeStr) {
setTheme(null) setTheme(null)
} }

View File

@ -1,53 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 { 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 { static get prefillRandomIcons(): boolean {
return localStorage.getItem('randomIcons') !== 'false' return UserSettings.get(UserSettingKey.RandomIcons) !== 'false'
} }
static set prefillRandomIcons(newValue: boolean) { static set prefillRandomIcons(newValue: boolean) {
localStorage.setItem('randomIcons', JSON.stringify(newValue)) UserSettings.set(UserSettingKey.RandomIcons, JSON.stringify(newValue))
}
} }
const keys = ['language', 'theme', 'lastBoardId', 'lastViewId', 'emoji-mart.last', 'emoji-mart.frequently', 'randomIcons'] 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
}
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 { export function exportUserSettingsBlob(): string {
return window.btoa(exportUserSettings()) return window.btoa(exportUserSettings())
} }
function exportUserSettings(): string { function exportUserSettings(): string {
const keys = Object.values(UserSettingKey)
const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)])) const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)]))
settings.timestamp = `${Date.now()}` settings.timestamp = `${Date.now()}`
return JSON.stringify(settings) return JSON.stringify(settings)
} }
export function importUserSettingsBlob(blob: string): boolean { export function importUserSettingsBlob(blob: string): string[] {
return importUserSettings(window.atob(blob)) return importUserSettings(window.atob(blob))
} }
function importUserSettings(json: string): boolean { function importUserSettings(json: string): string[] {
const settings = parseUserSettings(json) const settings = parseUserSettings(json)
if (!settings) {
return []
}
const timestamp = settings.timestamp const timestamp = settings.timestamp
const lastTimestamp = localStorage.getItem('timestamp') const lastTimestamp = localStorage.getItem('timestamp')
if (!timestamp || (lastTimestamp && Number(timestamp) <= Number(lastTimestamp))) { if (!timestamp || (lastTimestamp && Number(timestamp) <= Number(lastTimestamp))) {
return false return []
} }
const importedKeys = []
for (const [key, value] of Object.entries(settings)) { for (const [key, value] of Object.entries(settings)) {
if (Object.values(UserSettingKey).includes(key as UserSettingKey)) {
if (value) { if (value) {
localStorage.setItem(key, value as string) localStorage.setItem(key, value as string)
} else { } else {
localStorage.removeItem(key) localStorage.removeItem(key)
} }
importedKeys.push(key)
} }
return true }
return importedKeys
} }
function parseUserSettings(json: string): any { function parseUserSettings(json: string): any {
try { try {
return JSON.parse(json) return JSON.parse(json)
} catch (e) { } catch (e) {
return {} return undefined
} }
} }