Intro
This is a continuation of my series of blogs documenting my process of making a custom mod for Slack:
- Initial Creation of the Project discusses:
- Why I am making it
- How I inject into Slack
- Problem solving the unique challenges that come with client modding
- Very basic text editor that is linked to CSS
- Improving the Editor discusses:
- Learning from other open source works
- Adding an editor with Ace
localStorage
, a simple way to make saves persist.
Last blog left off with a finalized custom CSS editor the following note:
Given my list of issues from last blog, here is what I have left to do:
- Injecting is manual and hardcoded to the user's system
- I think I will make my next blog on this, followed by a tutorial on how to install it on your own slack!
Automatically finding Slack's install location
Because location varies wildly between OS, I started by looking into how to differentiate between user systems, and foundplatform.system()
which looks pretty nice.
Looking atthe docs, for Windows it returns "Windows", for Linux it returns "Linux", but the docs do not mention what it returns when a user is on a Mac!
Given this, I asked the only Mac user I currently know:
I got this response:
I never would've guessed that, glad I asked.
Given this info, I started off with a simple switch for what OS a user has installed.
# initialize slack location varslack_location=""# find slack location based on system user is runningmatchsystem():case"Linux":# code that gets location for linuxcase"Windows":# code that gets location for windowscase"Darwin":# Mac# code that gets location for maccase_:# happens when user doesn't have windows, mac, or linux# tell user their system isn't supported.
Linux
For the Linux case, as much as I would like to write something general, I don't know anyone else that uses Linux that would have Slack installed.
Given this, I just used what worked for my system, along with a little test usingos.path.exists()
.
If it the check fails, I usedinput()
to make sure the user sees the error, followed by an exit command.
case"Linux":# check if slack is in the locationslack_location="/usr/lib/slack/slack"if(notexists(slack_location)):input("Error Message")exit()
After a bit of thought, this is the error message I came up with:
Could not find Slack install! Please: - install Slack - open a Github issue so I can support your install locationPress Enter to exit.
I wrote this so it could be used as a catchall for whenever any Slack install wasn't found. This was so I could save it to a variable and use it for all not cases of Slack not being found:
# general error to print when slack cant be foundERR_SLACK_NOT_FOUND="Could not find Slack install! Please:\n\t- install Slack\n\t- open a Github issue so I can support your install location\nPress Enter to exit."# initialize slack location varslack_location=""# find slack location based on system user is runningmatchsystem():case"Linux":# check if slack is in the locationslack_location="/usr/lib/slack/slack"if(notexists(slack_location)):input(ERR_SLACK_NOT_FOUND)exit()...
Windows
Every Slack installation on Windows I have found has been at a directory following this format:
C:\Users\[USER_NAME]\AppData\Local\slack\app-[SLACK_VERSION]\slack.exe
After some research, I foundos.getenv('LOCALAPPDATA')
which returns:
C:\Users\[USER_NAME]\AppData\Local
(with [USER_NAME] filled in of course)
Given this, I first made a check for if the user has Slack installed at that location, using the ERR_SLACK_NOT_FOUND message we made earlier:
case"Windows":# Check if the %localappdata% location of slack existsslack_location=os.getenv('LOCALAPPDATA')+"\\slack"if(exists(slack_location)):# Code for getting the rest of itelse:input(ERR_SLACK_NOT_FOUND)exit()
The contents of the Slack directory we are at now look like this:
For some awful reason, Slack keeps older installations of itself, meaning now we have to do a lot of work to figure out which one of these is the directory we want.
Here's what we have to do in general:
- Get a list of all of the subdirectories in
%localappdata%/slack
- Filter for only the ones that will have
slack.exe
inside- As simple as checking if it contains
"app-"
- As simple as checking if it contains
- Get the latest version
- Surprisingly complex problem because version numbers don't work like number numbers
4.26.13
>4.26.3
>4.9.0
- Luckily, we are making this in Python, so someone will have already written a library for this.
- Surprisingly complex problem because version numbers don't work like number numbers
First things first, getting a list of the subdirectories. First google result gives usa highly voted stackoverflow answer:
However, you could use it just to give you the immediate child directories:
next(os.walk('.'))[1]
How this code works is pretty complicated to break down.
I'm going to just treat it like a black box, understanding its inputs and outputs.
If this project was one where people really needed to understand every line of the code, I would put it into a well commented function:
# Given a valid directory, outputs an array of sub directory names:# given an ExampleDirectory with the given structure:# ExampleDirectory:# ⎿ subDirectoryA# ⎿ subSubDirectory# ⎿ subDirectoryB# ⎿file.txt# Will Return:# ["subDirectoryA", "subDirectoryB"]defgetSubDirectories(directory):returnnext(walk(directory))[1]
But this isn't too big of a project, so I am just going to use it like this:
...if(exists(slack_location)):# Slack keeps old versions for some reason, inject into the latest one.# get an array of all the subdirectories# ['app-4.9.0', 'app-4.26.13', 'app-4.26.3', 'app-4.28.171', 'packages']subDirs=next(walk(slack_location))[1]
To filter these directories, we can use Python's filter method, which removes elements from an array based on what a function returns when given the input.
filter(function, array)
Using a lambda for our function looks like this:
filter(lambda dir: "app" in dir, subDirs)
"app-" in "app-4.23.0"
=> True- Keeps in array
"app-" in "packages"
=> False- Removes from array
Technically,filter()
returns a "filter object" so we have to wrap it inlist()
to cast it back to something useful.
Implementing this gives us the following function:
list(filter(lambda dir: "app" in dir, subDirs))
# Slack keeps old versions for some reason, inject into the latest one.# get an array of all the subdirectories that have app-# ['app-4.9.0', 'app-4.26.13', 'app-4.26.3', 'app-4.28.171']subDirs=list(filter(lambdadir:"app-"indir,next(walk(slack_location))[1]))
With this new array, all we need to do is get the latest version. Which should be pretty easy because Python has libraries for everything.
First Google results saidpackaging.version.Version()
.
In order to test if this works, I imported the library and initialized a test array inside the Python console:
>>>frompackaging.versionimportVersion>>>subDirs=['app-4.9.0','app-4.26.13','app-4.26.3','app-4.28.171']
To run it on every element on the array, we can usemap(function, array)
:
>>>map(Version,subDirs)<mapobjectat0x7f0d44e7f2b0>
Looks like similarly tofilter()
, we need to castmap()
's output to a list so its useful:
>>>list(map(Version,subDirs))Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/usr/lib/python3.10/site-packages/packaging/version.py",line266,in__init__raiseInvalidVersion(f"Invalid version:'{version}'")packaging.version.InvalidVersion:Invalidversion:'app-4.9.0'
Looks like we need to do a bit of pre-processing, as this library cant handle strings that aren't just version numbers.
To clean up our strings we can dostring.replace("app-","")
:
>>>"app-4.9.0".replace("app-","")'4.9.0'
To do that over our whole array, we can use map:
>>>list(map(lambdadir:dir.replace("app-",""),subDirs))['4.9.0','4.26.13','4.26.3','4.28.171']
TryingVersion
again with our cleaned strings:
>>>list(map(lambdadir:Version(dir.replace("app-","")),subDirs))[<Version('4.9.0')>,<Version('4.26.13')>,<Version('4.26.3')>,<Version('4.28.171')>]
Now we finally have an array of this library's version object.
Lets callmax()
on it to get the latest version:
>>>max(list(map(lambdadir:Version(dir.replace("app-","")),subDirs)))<Version('4.28.171')>
Looks promising, lets change our input data and see if it still works:
>>>subDirs=['app-4.28.171','app-4.26.3','app-4.26.13','app-4.9.0']>>>max(list(map(lambdadir:Version(dir.replace("app-","")),subDirs)))<Version('4.9.0')>
Looks like this library cant even do what it promised.
That may have felt like a massive waste of time, but it would've been a nightmare to debug that problem when it happened in the actual code.
After Googling again, I found a different library:pkg_resources.parse_version()
. Lets test it! Luckily, we can reuse a lot of the code we just used to test the previous library.
The first disappointing part of the previous library was it not being able to parse our strings while they still hadapp-
in them. So I decided to first test if our new library needs the same cleanup:
>>>frompkg_resourcesimportparse_version>>>list(map(parse_version,subDirs))[<LegacyVersion('app-4.28.171')>,<LegacyVersion('app-4.26.3')>,<LegacyVersion('app-4.26.13')>,<LegacyVersion('app-4.9.0')>]
Great, less parsing overall needed for us!
But does it actually compare these correctly? Lets test:
>>>max(list(map(parse_version,subDirs)))<LegacyVersion('app-4.28.171')>
YES!
Alright, let's implement this into our code!
Putting in what we have looks like this:
...# ['app-4.9.0', 'app-4.26.13', 'app-4.26.3', 'app-4.28.171']subDirs=list(filter(lambdadir:"app-"indir,next(walk(slack_location))[1]))# a "LegacyVersion" object of the newest version in subDirs# <LegacyVersion('app-4.28.171')>latest_version=max(list(map(parse_version,subDirs)))
Now what? For me, finding how to get the latest version took so long that I forgot what we even needed it for.
The whole point of this was to get which Slack subdirectory to use, after remembering this, I realized aLegacyVersion
object isn't very useful as there is no easy way to turn it back into the correct subdirectory.
Given this, I put our array ofLegacyVersion
objects into a variable instead:
# array of comparable "LegacyVersion" objects in the same order as subDirsversions=list(map(parse_version,subDirs))
Then, I got the index of the latest version in that array:
versions.index(max(versions))
Becauseversions
has the same order assubDirs
, we can use that index in oursubDirs
array to get the directory containing the newest version of Slack:
# the directory containing the newest version of slack:subDirs[versions.index(max(versions))]
With that, we can finally get the full directory to the latest Windows version of Slack.
# Check if the %appdata% location of slack existsslack_location=os.getenv('LOCALAPPDATA')+"\\slack"if(exists(slack_location)):# Slack keeps old versions for some reason, we want to inject into the latest one# get an array of all the subdirectories that have app-# ['app-4.9.0', 'app-4.26.13', 'app-4.26.3', 'app-4.28.171']subDirs=list(filter(lambdadir:"app-"indir,next(walk(slack_location))[1]))# array of comparable "LegacyVersion" objects in the same order as subDirsversions=list(map(parse_version,subDirs))# The full path to the latest version of Slack's slack.exeslack_location='"'+slack_location+"\\"+subDirs[versions.index(max(versions))]+"\\slack.exe"+'"'else:# If Slack is not found in windows appdataprint(ERR_SLACK_NOT_FOUND)exit()
MacOS
I don't have a Mac, so this is going to be a pain.
I started by asking the one person that uses a Mac and Slack if he knew where Slack is installed:
Given this, I asked him to try running a few commands in his Python terminal as a test to see if what I was about to do would work.
After that, I was tempted to just give up on MacOS and use the following code:
case"Darwin":# Macinput("MacOS is not supported yet\n\tpress any key to exit")exit()
But I wasn't satisfied with that.
I thought about it some more, realized MacOS may just not display the full path to the user, and asked my friend tocd
to/Computer/Applications/
and runpwd
(print working directory).
His response took a bit, but it was worth it:
With that, I knew at least this bit of code would work:
case"Darwin":# Mac# check if slack is in the locationslack_location="/Applications/Slack.app"if(notexists(slack_location)):input(ERR_SLACK_NOT_FOUND)exit()
I pushed my changes and asked my friend to try using my mod.
He sent back a stacktrace, notably containing this:
Traceback (most recent call last): File "/Users/thelightdisk/Development/SlackMod/slack_launch.py", line 72, in <module> download(libURL)
...meaning there was some error with my download code.
To narrow down the issue, I asked him to try and run some more stuff in the Python terminal as a test:
~python3>fromwgetimportdownload>wget("https://ajaxorg.github.io/ace-builds/src-min-noconflict/ace.js")
In response, he sent back an error containing the exact same issue:
Given this, I knew there was some issue withwget.download()
on MacOS.
I am going to skip over around 2 hours of me having him try different download libraries in an attempt to find one that worked on mac.
Eventually I came to the conclusion that I should just do this:
# macOS is stupid and doesn't like wget's downloaddefdownload(url):if(system()=="Darwin"):fileName=url.split("/")[-1]sysrun(f"curl -o{fileName}\"{url}\"")sleep(0.1)else:wgetDownload(url)
After that, all of the downloading part of the script worked, but then it spat out an error thatSlack.app
is a directory.
I asked my friend to get the folder structure withls
and he did one better:
Given this, I updated the Macslack_location
one last time:
case"Darwin":# Mac# check if slack is in the locationslack_location="/Applications/Slack.app/Contents/MacOS/Slack"if(notexists(slack_location)):input(ERR_SLACK_NOT_FOUND)exit()
With that change, it finally worked:
Automatically Killing Slack
Throughout having friends test the mod in the prior step, them forgetting close Slack when running the script was a pretty common issue.
Given that, I figured it would be a good idea to have the script do it automatically.
I found some solid looking code forkilling a process with Python on StackOverflow, so I modified it a slight bit and gave credit in a comment:
# Kill Slack# Mac and Linux have no extension on executablesprocess_name="slack"if(system()=="Windows"):process_name="slack.exe"# https://stackoverflow.com/a/67509457/8133370 under (CC BY-SA 4.0)try:print(f'Killing Slack Processes')forprocessinprocess_iter():try:ifprocess_name==process.name()orprocess_nameinprocess.cmdline():args=process.cmdline()if(len(args)>4):args=args[0:3]print(f'\tkilling instance -{args}')process.terminate()exceptException:print(f"\t\tPermission Denied, Moving on.")exceptException:print(f"Failed to get processes to kill, assuming Slack isn't open")
Note that instead of printing an exception stacktrace I went for 2 generic errors. This is because the following errors occur on Windows when there are no real problems:
On occasion, when running the launch script on Windows without Slack open, it triggered the outer try statement. Given this, I replaced the error with this:
Failed to get processes to kill, assuming Slack isn't open
On Windows, there are some Slack processes that have elevated privileges and can't be killed by our script. When we try to kill them, it triggers the inner try statement. Thing is, these processes are automatically closed when we kill the non-elevated processes.
Given that it really doesn't matter we can't kill them, I replaced the error with this:
Permission Denied, Moving on.
Other than those small tweaks, the script from StackOverflow worked perfectly on all systems I tested.
Conclusion
Remaining issues
After all of that, using SlackMod is still a bit of a pain.
To install it, you have to:
- Download Python 3
- Download the mod from GitHub
- Install the needed Python libraries
After you do that, every time you want to open it, you have to do the following:
- Open a terminal
- Navigate to wherever you installed the mod
- Run
python3 slack_launch.py
Eventually, I will attempt to address this, but solving this problem has proved VERY difficult.
Meta and Plan for next Blogs
I originally wanted this to be a short blog covering the process of making installing and using the mod easier. It turned out that just fixing the launch script was a royal pain, but that has been complete for nearly a week.
I have spent the last week trying to find a neat way to make the mod easier to launch, while also making it easier to install. I have completely failed at that.
If you care, here is a quick summary of all the ideas I have gone through:
- Make adding an OS-specific script to OS's app launch directory part of the "install process".
- Adds a lot of effort to setup process
- Hard to explain
- Varies wildly by OS
- Hundreds of ways to do this on Windows, all of which feel wrong.
- Making a separate OS-specific script that automatically does the above.
- Turns out to be so complex I don't know how to get into it.
- Compiling python script into 3 OS-specific executable files
- Still painful to add to OS app launch directory
- Compiling Python is PAIN
- How the hell will
electron-inject
see the JS?
Next blog will likely be some final cleanup to our injected JavaScript, which should be quickly followed by an "official" v1 release blog with a detailed install tutorial.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse